Replace go-openai with native client
Drop the github.com/sashabaranov/go-openai dependency in favor of an internal HTTP client. The LLM package is now split into: - llm.go: type definitions (Message, ChatRequest, ChatResponse, etc.) - client.go: client construction and configuration - client_completions.go: Completions and CompletionsStream methods - client_models.go: ListModels - client_tools.go: tool call execution CompletionsStream uses typed SSE events (delta, done, message) so the frontend can render progressive streaming output. Move tool.go and tool_test.go from internal/tool/ into internal/llm/ to co-locate the registry with the client that consumes it. Update job.go and service.go to use the new llm.Message types and Completions/CompletionsStream methods. Remove the Voice field from chat request/response (voice is now tracked via message metadata). Frontend: handle delta/done/message SSE events, render a streaming placeholder that receives progressive text deltas, and finalize with full message binding (reasoning, tool calls, actions).
This commit is contained in:
208
internal/llm/llm_integration_test.go
Normal file
208
internal/llm/llm_integration_test.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"code.chimeric.al/chimerical/odidere/internal/config"
|
||||
)
|
||||
|
||||
// testURL returns the LLM endpoint for integration tests.
|
||||
// Set ODIDERE_LLM_TEST_URL to enable; tests skip if empty.
|
||||
func testURL(t *testing.T) string {
|
||||
t.Helper()
|
||||
url := os.Getenv("ODIDERE_LLM_TEST_URL")
|
||||
if url == "" {
|
||||
t.Skip("ODIDERE_LLM_TEST_URL not set")
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// testKey returns the API key for integration tests.
|
||||
// Reads ODIDERE_LLM_TEST_KEY; empty string is valid (no auth).
|
||||
func testKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
return os.Getenv("ODIDERE_LLM_TEST_KEY")
|
||||
}
|
||||
|
||||
// testModel returns the model ID for integration tests.
|
||||
// Reads ODIDERE_LLM_TEST_MODEL; defaults to "test" if empty.
|
||||
func testModel(t *testing.T) string {
|
||||
t.Helper()
|
||||
model := os.Getenv("ODIDERE_LLM_TEST_MODEL")
|
||||
if model == "" {
|
||||
return "test"
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func TestIntegration_ListModels(t *testing.T) {
|
||||
_ = testURL(t)
|
||||
cfg := Config{
|
||||
URL: testURL(t),
|
||||
Key: testKey(t),
|
||||
Timeout: "30s",
|
||||
}
|
||||
client, err := NewClient(cfg, nil, slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
|
||||
models, err := client.ListModels(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListModels: %v", err)
|
||||
}
|
||||
if len(models) == 0 {
|
||||
t.Error("expected at least one model")
|
||||
}
|
||||
for _, m := range models {
|
||||
if m.ID == "" {
|
||||
t.Error("model has empty ID")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Completions(t *testing.T) {
|
||||
_ = testURL(t)
|
||||
cfg := Config{
|
||||
URL: testURL(t),
|
||||
Key: testKey(t),
|
||||
Timeout: "60s",
|
||||
}
|
||||
client, err := NewClient(cfg, nil, slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
|
||||
msgs, err := client.Completions(
|
||||
context.Background(),
|
||||
testModel(t),
|
||||
"",
|
||||
0,
|
||||
[]Message{{
|
||||
Role: RoleUser,
|
||||
ContentParts: []ContentPart{{
|
||||
Type: "text",
|
||||
Text: "Say hello in three words.",
|
||||
}},
|
||||
}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Completions: %v", err)
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
t.Fatal("expected at least one message")
|
||||
}
|
||||
text := msgs[len(msgs)-1].Text()
|
||||
if text == "" {
|
||||
t.Error("final message has no text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_CompletionsStream(t *testing.T) {
|
||||
_ = testURL(t)
|
||||
cfg := Config{
|
||||
URL: testURL(t),
|
||||
Key: testKey(t),
|
||||
Timeout: "60s",
|
||||
}
|
||||
client, err := NewClient(cfg, nil, slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
|
||||
events := make(chan StreamEvent)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- client.CompletionsStream(
|
||||
context.Background(),
|
||||
testModel(t),
|
||||
"",
|
||||
0,
|
||||
[]Message{{
|
||||
Role: RoleUser,
|
||||
ContentParts: []ContentPart{{
|
||||
Type: "text",
|
||||
Text: "Say hello in three words.",
|
||||
}},
|
||||
}},
|
||||
events,
|
||||
)
|
||||
}()
|
||||
|
||||
var messages []Message
|
||||
for evt := range events {
|
||||
messages = append(messages, evt.Message)
|
||||
}
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatalf("CompletionsStream: %v", err)
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
t.Fatal("expected at least one message")
|
||||
}
|
||||
text := messages[len(messages)-1].Text()
|
||||
if text == "" {
|
||||
t.Error("final message has no text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_ToolCalling(t *testing.T) {
|
||||
_ = testURL(t)
|
||||
|
||||
// Build a minimal tool registry with a single echo tool.
|
||||
reg, err := NewRegistry([]config.ToolConfig{
|
||||
{
|
||||
Name: "echo",
|
||||
Description: "Echo back the input string",
|
||||
Command: "echo",
|
||||
Arguments: []string{"{{.input}}"},
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"input": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []string{"input"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRegistry: %v", err)
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
URL: testURL(t),
|
||||
Key: testKey(t),
|
||||
Timeout: "60s",
|
||||
}
|
||||
client, err := NewClient(cfg, reg, slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
|
||||
msgs, err := client.Completions(
|
||||
context.Background(),
|
||||
testModel(t),
|
||||
"",
|
||||
10,
|
||||
[]Message{{
|
||||
Role: RoleUser,
|
||||
ContentParts: []ContentPart{{
|
||||
Type: "text",
|
||||
Text: "Use the echo tool to say 'hello'",
|
||||
}},
|
||||
}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Completions: %v", err)
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
t.Fatal("expected at least one message")
|
||||
}
|
||||
// The final message should be a non-tool-call assistant message.
|
||||
final := msgs[len(msgs)-1]
|
||||
if len(final.ToolCalls) > 0 {
|
||||
t.Error("final message still has tool calls")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user