// Package openaicompat adapts the existing internal/llm.Client (a small // OpenAI-compatible chat-completions HTTP client) to the provider-neutral // provider.Provider interface, unchanged in its own behavior — this package // only translates request/response shapes. package openaicompat import ( "context" "fmt" "github.com/thepeterstone/claudomator/internal/llm" "github.com/thepeterstone/claudomator/internal/provider" ) // Provider wraps an *llm.Client so it can be driven through the // provider-neutral interface. type Provider struct { Client *llm.Client } // New returns a provider.Provider backed by client. func New(client *llm.Client) *Provider { return &Provider{Client: client} } var _ provider.Provider = (*Provider)(nil) func (p *Provider) Name() string { return "openaicompat" } // Chat translates req into an llm.ChatRequest, performs the call via the // wrapped client, and translates the result back. func (p *Provider) Chat(ctx context.Context, req provider.ChatRequest) (*provider.ChatResponse, error) { if p == nil || p.Client == nil { return nil, fmt.Errorf("openaicompat: nil client") } llmReq := toLLMRequest(req) resp, err := p.Client.Chat(ctx, llmReq) if err != nil { return nil, err } return fromLLMResponse(resp), nil } // toLLMRequest translates a provider-neutral ChatRequest into the wire shape // llm.Client understands. System, if set, becomes a leading role:"system" // message — llm.Client/the OpenAI-compatible wire format has no separate // top-level system field. func toLLMRequest(req provider.ChatRequest) llm.ChatRequest { messages := make([]llm.Message, 0, len(req.Messages)+1) if req.System != "" { messages = append(messages, llm.Message{Role: "system", Content: req.System}) } for _, m := range req.Messages { messages = append(messages, toLLMMessages(m)...) } var tools []llm.Tool if len(req.Tools) > 0 { tools = make([]llm.Tool, 0, len(req.Tools)) for _, ts := range req.Tools { tools = append(tools, llm.Tool{ Type: "function", Function: llm.ToolFunction{ Name: ts.Name, Description: ts.Description, Parameters: ts.ParametersJSONSchema, }, }) } } return llm.ChatRequest{ Model: req.Model, Messages: messages, Temperature: req.Temperature, MaxTokens: req.MaxTokens, Tools: tools, } } // toLLMMessages translates a single provider-neutral Message into zero or more // llm.Message values. Assistant turns (with ToolCalls) and plain text turns // translate 1:1. Tool-result turns translate to one llm.Message per // ToolResult, since the OpenAI wire format represents each tool result as its // own role:"tool" message (agentloop always emits one ToolResult per turn // today, matching that shape exactly; the loop here is future-proofing for // providers/loops that batch multiple results into one turn). func toLLMMessages(m provider.Message) []llm.Message { if len(m.ToolResults) > 0 { out := make([]llm.Message, 0, len(m.ToolResults)) for _, tr := range m.ToolResults { out = append(out, llm.Message{ Role: "tool", ToolCallID: tr.ToolCallID, Name: tr.Name, Content: tr.Content, }) } return out } lm := llm.Message{Role: m.Role, Content: m.Text} if len(m.ToolCalls) > 0 { lm.ToolCalls = make([]llm.ToolCall, 0, len(m.ToolCalls)) for _, tc := range m.ToolCalls { lm.ToolCalls = append(lm.ToolCalls, llm.ToolCall{ ID: tc.ID, Type: "function", Function: llm.ToolCallFunction{ Name: tc.Name, Arguments: tc.ArgsJSON, }, }) } } return []llm.Message{lm} } func fromLLMResponse(r *llm.ChatResponse) *provider.ChatResponse { var calls []provider.ToolCall if len(r.ToolCalls) > 0 { calls = make([]provider.ToolCall, 0, len(r.ToolCalls)) for _, tc := range r.ToolCalls { calls = append(calls, provider.ToolCall{ ID: tc.ID, Name: tc.Function.Name, ArgsJSON: tc.Function.Arguments, }) } } return &provider.ChatResponse{ Text: r.Content, ToolCalls: calls, StopReason: r.FinishReason, Usage: provider.Usage{ InputTokens: r.PromptTokens, OutputTokens: r.OutputTokens, }, } }