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:
dwrz
2026-06-26 14:22:05 +00:00
parent e480538ec2
commit f6fb21d40c
56 changed files with 1671 additions and 7854 deletions

View 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")
}
}