2026-02-15 13:04:07 +00:00
|
|
|
package openai_compat
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
2026-02-17 16:13:10 +00:00
|
|
|
"log"
|
2026-02-15 13:04:07 +00:00
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-02-17 16:13:10 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
|
|
|
|
)
|
2026-02-15 13:04:07 +00:00
|
|
|
|
2026-02-20 18:03:11 +00:00
|
|
|
type (
|
|
|
|
|
ToolCall = protocoltypes.ToolCall
|
|
|
|
|
FunctionCall = protocoltypes.FunctionCall
|
|
|
|
|
LLMResponse = protocoltypes.LLMResponse
|
|
|
|
|
UsageInfo = protocoltypes.UsageInfo
|
|
|
|
|
Message = protocoltypes.Message
|
|
|
|
|
ToolDefinition = protocoltypes.ToolDefinition
|
|
|
|
|
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
|
|
|
|
ExtraContent = protocoltypes.ExtraContent
|
|
|
|
|
GoogleExtra = protocoltypes.GoogleExtra
|
2026-02-26 05:24:51 +00:00
|
|
|
ReasoningDetail = protocoltypes.ReasoningDetail
|
2026-02-20 18:03:11 +00:00
|
|
|
)
|
2026-02-15 13:04:07 +00:00
|
|
|
|
|
|
|
|
type Provider struct {
|
2026-02-19 16:12:01 +00:00
|
|
|
apiKey string
|
|
|
|
|
apiBase string
|
|
|
|
|
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
|
|
|
|
|
httpClient *http.Client
|
2026-02-15 13:04:07 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 08:08:19 +00:00
|
|
|
type Option func(*Provider)
|
|
|
|
|
|
|
|
|
|
const defaultRequestTimeout = 120 * time.Second
|
|
|
|
|
|
|
|
|
|
func WithMaxTokensField(maxTokensField string) Option {
|
|
|
|
|
return func(p *Provider) {
|
|
|
|
|
p.maxTokensField = maxTokensField
|
|
|
|
|
}
|
2026-02-19 16:12:01 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 08:08:19 +00:00
|
|
|
func WithRequestTimeout(timeout time.Duration) Option {
|
|
|
|
|
return func(p *Provider) {
|
|
|
|
|
if timeout > 0 {
|
|
|
|
|
p.httpClient.Timeout = timeout
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
|
2026-02-15 13:04:07 +00:00
|
|
|
client := &http.Client{
|
2026-02-26 08:08:19 +00:00
|
|
|
Timeout: defaultRequestTimeout,
|
2026-02-15 13:04:07 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-17 16:13:10 +00:00
|
|
|
if proxy != "" {
|
|
|
|
|
parsed, err := url.Parse(proxy)
|
2026-02-15 13:04:07 +00:00
|
|
|
if err == nil {
|
|
|
|
|
client.Transport = &http.Transport{
|
|
|
|
|
Proxy: http.ProxyURL(parsed),
|
|
|
|
|
}
|
2026-02-17 16:13:10 +00:00
|
|
|
} else {
|
|
|
|
|
log.Printf("openai_compat: invalid proxy URL %q: %v", proxy, err)
|
2026-02-15 13:04:07 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 08:08:19 +00:00
|
|
|
p := &Provider{
|
|
|
|
|
apiKey: apiKey,
|
|
|
|
|
apiBase: strings.TrimRight(apiBase, "/"),
|
|
|
|
|
httpClient: client,
|
2026-02-15 13:04:07 +00:00
|
|
|
}
|
2026-02-26 08:08:19 +00:00
|
|
|
|
|
|
|
|
for _, opt := range opts {
|
|
|
|
|
if opt != nil {
|
|
|
|
|
opt(p)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return p
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *Provider {
|
|
|
|
|
return NewProvider(apiKey, apiBase, proxy, WithMaxTokensField(maxTokensField))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewProviderWithMaxTokensFieldAndTimeout(
|
|
|
|
|
apiKey, apiBase, proxy, maxTokensField string,
|
|
|
|
|
requestTimeoutSeconds int,
|
|
|
|
|
) *Provider {
|
|
|
|
|
return NewProvider(
|
|
|
|
|
apiKey,
|
|
|
|
|
apiBase,
|
|
|
|
|
proxy,
|
|
|
|
|
WithMaxTokensField(maxTokensField),
|
|
|
|
|
WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
|
|
|
|
|
)
|
2026-02-15 13:04:07 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-20 18:03:11 +00:00
|
|
|
func (p *Provider) Chat(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
messages []Message,
|
|
|
|
|
tools []ToolDefinition,
|
|
|
|
|
model string,
|
|
|
|
|
options map[string]any,
|
|
|
|
|
) (*LLMResponse, error) {
|
2026-02-15 13:04:07 +00:00
|
|
|
if p.apiBase == "" {
|
|
|
|
|
return nil, fmt.Errorf("API base not configured")
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 16:13:10 +00:00
|
|
|
model = normalizeModel(model, p.apiBase)
|
2026-02-15 13:04:07 +00:00
|
|
|
|
2026-02-20 18:03:11 +00:00
|
|
|
requestBody := map[string]any{
|
2026-02-15 13:04:07 +00:00
|
|
|
"model": model,
|
fix: cache system prompt with mtime-based auto-invalidation (#607)
Avoid rebuilding the entire system prompt on every BuildMessages() call
by caching the static portion (identity, bootstrap, skills summary,
memory) and only recomputing it when workspace source files change.
Key changes:
- ContextBuilder caches the static prompt behind an RWMutex with
double-checked locking. Source file changes are detected via cheap
os.Stat mtime checks so no explicit invalidation is needed.
- Track file existence at cache time (existedAtCache map) so that
newly created or deleted bootstrap/memory files also trigger a
rebuild — the old modifiedSince() silently returned false on
os.IsNotExist.
- Walk the skills directory recursively with filepath.WalkDir to
catch content-only edits at any nesting depth; directory mtime
alone misses in-place file modifications on most filesystems.
- ToolRegistry.sortedToolNames() sorts tool names before iteration,
ensuring deterministic tool definition order across calls — a
prerequisite for LLM-side prefix/KV cache reuse.
- Merge all context (static + dynamic + summary) into a single
system message for provider compatibility: the Anthropic adapter
extracts messages[0] as the top-level system parameter, and Codex
reads only the first system message as instructions.
- Fix a data race in BuildMessages() where cachedSystemPrompt was
read without holding the lock in a debug log statement.
- Add tests: single system message invariant, mtime auto-invalidation,
new-file creation detection, skill file content change, explicit
InvalidateCache, cache stability, concurrent access (20 goroutines
x 50 iterations, passes go test -race), and a benchmark.
2026-02-25 02:34:54 +00:00
|
|
|
"messages": stripSystemParts(messages),
|
2026-02-15 13:04:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(tools) > 0 {
|
|
|
|
|
requestBody["tools"] = tools
|
|
|
|
|
requestBody["tool_choice"] = "auto"
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 16:13:10 +00:00
|
|
|
if maxTokens, ok := asInt(options["max_tokens"]); ok {
|
2026-02-19 16:12:01 +00:00
|
|
|
// Use configured maxTokensField if specified, otherwise fallback to model-based detection
|
|
|
|
|
fieldName := p.maxTokensField
|
|
|
|
|
if fieldName == "" {
|
|
|
|
|
// Fallback: detect from model name for backward compatibility
|
|
|
|
|
lowerModel := strings.ToLower(model)
|
2026-02-20 18:03:11 +00:00
|
|
|
if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") ||
|
|
|
|
|
strings.Contains(lowerModel, "gpt-5") {
|
2026-02-19 16:12:01 +00:00
|
|
|
fieldName = "max_completion_tokens"
|
|
|
|
|
} else {
|
|
|
|
|
fieldName = "max_tokens"
|
|
|
|
|
}
|
2026-02-15 13:04:07 +00:00
|
|
|
}
|
2026-02-19 16:12:01 +00:00
|
|
|
requestBody[fieldName] = maxTokens
|
2026-02-15 13:04:07 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-17 16:13:10 +00:00
|
|
|
if temperature, ok := asFloat(options["temperature"]); ok {
|
2026-02-15 13:04:07 +00:00
|
|
|
lowerModel := strings.ToLower(model)
|
|
|
|
|
// Kimi k2 models only support temperature=1.
|
|
|
|
|
if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
|
|
|
|
|
requestBody["temperature"] = 1.0
|
|
|
|
|
} else {
|
|
|
|
|
requestBody["temperature"] = temperature
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
fix: cache system prompt with mtime-based auto-invalidation (#607)
Avoid rebuilding the entire system prompt on every BuildMessages() call
by caching the static portion (identity, bootstrap, skills summary,
memory) and only recomputing it when workspace source files change.
Key changes:
- ContextBuilder caches the static prompt behind an RWMutex with
double-checked locking. Source file changes are detected via cheap
os.Stat mtime checks so no explicit invalidation is needed.
- Track file existence at cache time (existedAtCache map) so that
newly created or deleted bootstrap/memory files also trigger a
rebuild — the old modifiedSince() silently returned false on
os.IsNotExist.
- Walk the skills directory recursively with filepath.WalkDir to
catch content-only edits at any nesting depth; directory mtime
alone misses in-place file modifications on most filesystems.
- ToolRegistry.sortedToolNames() sorts tool names before iteration,
ensuring deterministic tool definition order across calls — a
prerequisite for LLM-side prefix/KV cache reuse.
- Merge all context (static + dynamic + summary) into a single
system message for provider compatibility: the Anthropic adapter
extracts messages[0] as the top-level system parameter, and Codex
reads only the first system message as instructions.
- Fix a data race in BuildMessages() where cachedSystemPrompt was
read without holding the lock in a debug log statement.
- Add tests: single system message invariant, mtime auto-invalidation,
new-file creation detection, skill file content change, explicit
InvalidateCache, cache stability, concurrent access (20 goroutines
x 50 iterations, passes go test -race), and a benchmark.
2026-02-25 02:34:54 +00:00
|
|
|
// Prompt caching: pass a stable cache key so OpenAI can bucket requests
|
|
|
|
|
// with the same key and reuse prefix KV cache across calls.
|
|
|
|
|
// The key is typically the agent ID — stable per agent, shared across requests.
|
|
|
|
|
// See: https://platform.openai.com/docs/guides/prompt-caching
|
2026-02-25 15:09:46 +00:00
|
|
|
// Prompt caching is only supported by OpenAI-native endpoints.
|
|
|
|
|
// Gemini and other providers reject unknown fields, so skip for non-OpenAI APIs.
|
fix: cache system prompt with mtime-based auto-invalidation (#607)
Avoid rebuilding the entire system prompt on every BuildMessages() call
by caching the static portion (identity, bootstrap, skills summary,
memory) and only recomputing it when workspace source files change.
Key changes:
- ContextBuilder caches the static prompt behind an RWMutex with
double-checked locking. Source file changes are detected via cheap
os.Stat mtime checks so no explicit invalidation is needed.
- Track file existence at cache time (existedAtCache map) so that
newly created or deleted bootstrap/memory files also trigger a
rebuild — the old modifiedSince() silently returned false on
os.IsNotExist.
- Walk the skills directory recursively with filepath.WalkDir to
catch content-only edits at any nesting depth; directory mtime
alone misses in-place file modifications on most filesystems.
- ToolRegistry.sortedToolNames() sorts tool names before iteration,
ensuring deterministic tool definition order across calls — a
prerequisite for LLM-side prefix/KV cache reuse.
- Merge all context (static + dynamic + summary) into a single
system message for provider compatibility: the Anthropic adapter
extracts messages[0] as the top-level system parameter, and Codex
reads only the first system message as instructions.
- Fix a data race in BuildMessages() where cachedSystemPrompt was
read without holding the lock in a debug log statement.
- Add tests: single system message invariant, mtime auto-invalidation,
new-file creation detection, skill file content change, explicit
InvalidateCache, cache stability, concurrent access (20 goroutines
x 50 iterations, passes go test -race), and a benchmark.
2026-02-25 02:34:54 +00:00
|
|
|
if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" {
|
2026-02-25 15:09:46 +00:00
|
|
|
if !strings.Contains(p.apiBase, "generativelanguage.googleapis.com") {
|
|
|
|
|
requestBody["prompt_cache_key"] = cacheKey
|
|
|
|
|
}
|
fix: cache system prompt with mtime-based auto-invalidation (#607)
Avoid rebuilding the entire system prompt on every BuildMessages() call
by caching the static portion (identity, bootstrap, skills summary,
memory) and only recomputing it when workspace source files change.
Key changes:
- ContextBuilder caches the static prompt behind an RWMutex with
double-checked locking. Source file changes are detected via cheap
os.Stat mtime checks so no explicit invalidation is needed.
- Track file existence at cache time (existedAtCache map) so that
newly created or deleted bootstrap/memory files also trigger a
rebuild — the old modifiedSince() silently returned false on
os.IsNotExist.
- Walk the skills directory recursively with filepath.WalkDir to
catch content-only edits at any nesting depth; directory mtime
alone misses in-place file modifications on most filesystems.
- ToolRegistry.sortedToolNames() sorts tool names before iteration,
ensuring deterministic tool definition order across calls — a
prerequisite for LLM-side prefix/KV cache reuse.
- Merge all context (static + dynamic + summary) into a single
system message for provider compatibility: the Anthropic adapter
extracts messages[0] as the top-level system parameter, and Codex
reads only the first system message as instructions.
- Fix a data race in BuildMessages() where cachedSystemPrompt was
read without holding the lock in a debug log statement.
- Add tests: single system message invariant, mtime auto-invalidation,
new-file creation detection, skill file content change, explicit
InvalidateCache, cache stability, concurrent access (20 goroutines
x 50 iterations, passes go test -race), and a benchmark.
2026-02-25 02:34:54 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-15 13:04:07 +00:00
|
|
|
jsonData, err := json.Marshal(requestBody)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
if p.apiKey != "" {
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return parseResponse(body)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parseResponse(body []byte) (*LLMResponse, error) {
|
|
|
|
|
var apiResponse struct {
|
|
|
|
|
Choices []struct {
|
|
|
|
|
Message struct {
|
2026-02-26 05:24:51 +00:00
|
|
|
Content string `json:"content"`
|
|
|
|
|
ReasoningContent string `json:"reasoning_content"`
|
|
|
|
|
Reasoning string `json:"reasoning"`
|
|
|
|
|
ReasoningDetails []ReasoningDetail `json:"reasoning_details"`
|
2026-02-21 15:29:40 +00:00
|
|
|
ToolCalls []struct {
|
2026-02-15 13:04:07 +00:00
|
|
|
ID string `json:"id"`
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
Function *struct {
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Arguments string `json:"arguments"`
|
|
|
|
|
} `json:"function"`
|
2026-02-19 16:36:31 +00:00
|
|
|
ExtraContent *struct {
|
|
|
|
|
Google *struct {
|
|
|
|
|
ThoughtSignature string `json:"thought_signature"`
|
|
|
|
|
} `json:"google"`
|
|
|
|
|
} `json:"extra_content"`
|
2026-02-15 13:04:07 +00:00
|
|
|
} `json:"tool_calls"`
|
|
|
|
|
} `json:"message"`
|
|
|
|
|
FinishReason string `json:"finish_reason"`
|
|
|
|
|
} `json:"choices"`
|
|
|
|
|
Usage *UsageInfo `json:"usage"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := json.Unmarshal(body, &apiResponse); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(apiResponse.Choices) == 0 {
|
|
|
|
|
return &LLMResponse{
|
|
|
|
|
Content: "",
|
|
|
|
|
FinishReason: "stop",
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
choice := apiResponse.Choices[0]
|
|
|
|
|
toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
|
|
|
|
|
for _, tc := range choice.Message.ToolCalls {
|
2026-02-20 18:03:11 +00:00
|
|
|
arguments := make(map[string]any)
|
2026-02-15 13:04:07 +00:00
|
|
|
name := ""
|
|
|
|
|
|
2026-02-19 16:36:31 +00:00
|
|
|
// Extract thought_signature from Gemini/Google-specific extra content
|
|
|
|
|
thoughtSignature := ""
|
|
|
|
|
if tc.ExtraContent != nil && tc.ExtraContent.Google != nil {
|
|
|
|
|
thoughtSignature = tc.ExtraContent.Google.ThoughtSignature
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 16:13:10 +00:00
|
|
|
if tc.Function != nil {
|
2026-02-15 13:04:07 +00:00
|
|
|
name = tc.Function.Name
|
|
|
|
|
if tc.Function.Arguments != "" {
|
|
|
|
|
if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
|
2026-02-17 16:13:10 +00:00
|
|
|
log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err)
|
2026-02-15 13:04:07 +00:00
|
|
|
arguments["raw"] = tc.Function.Arguments
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 16:36:31 +00:00
|
|
|
// Build ToolCall with ExtraContent for Gemini 3 thought_signature persistence
|
|
|
|
|
toolCall := ToolCall{
|
|
|
|
|
ID: tc.ID,
|
|
|
|
|
Name: name,
|
|
|
|
|
Arguments: arguments,
|
|
|
|
|
ThoughtSignature: thoughtSignature,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if thoughtSignature != "" {
|
|
|
|
|
toolCall.ExtraContent = &ExtraContent{
|
|
|
|
|
Google: &GoogleExtra{
|
|
|
|
|
ThoughtSignature: thoughtSignature,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toolCalls = append(toolCalls, toolCall)
|
2026-02-15 13:04:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &LLMResponse{
|
2026-02-21 15:29:40 +00:00
|
|
|
Content: choice.Message.Content,
|
|
|
|
|
ReasoningContent: choice.Message.ReasoningContent,
|
2026-02-26 05:24:51 +00:00
|
|
|
Reasoning: choice.Message.Reasoning,
|
|
|
|
|
ReasoningDetails: choice.Message.ReasoningDetails,
|
2026-02-21 15:29:40 +00:00
|
|
|
ToolCalls: toolCalls,
|
|
|
|
|
FinishReason: choice.FinishReason,
|
|
|
|
|
Usage: apiResponse.Usage,
|
2026-02-15 13:04:07 +00:00
|
|
|
}, nil
|
|
|
|
|
}
|
2026-02-17 16:13:10 +00:00
|
|
|
|
fix: cache system prompt with mtime-based auto-invalidation (#607)
Avoid rebuilding the entire system prompt on every BuildMessages() call
by caching the static portion (identity, bootstrap, skills summary,
memory) and only recomputing it when workspace source files change.
Key changes:
- ContextBuilder caches the static prompt behind an RWMutex with
double-checked locking. Source file changes are detected via cheap
os.Stat mtime checks so no explicit invalidation is needed.
- Track file existence at cache time (existedAtCache map) so that
newly created or deleted bootstrap/memory files also trigger a
rebuild — the old modifiedSince() silently returned false on
os.IsNotExist.
- Walk the skills directory recursively with filepath.WalkDir to
catch content-only edits at any nesting depth; directory mtime
alone misses in-place file modifications on most filesystems.
- ToolRegistry.sortedToolNames() sorts tool names before iteration,
ensuring deterministic tool definition order across calls — a
prerequisite for LLM-side prefix/KV cache reuse.
- Merge all context (static + dynamic + summary) into a single
system message for provider compatibility: the Anthropic adapter
extracts messages[0] as the top-level system parameter, and Codex
reads only the first system message as instructions.
- Fix a data race in BuildMessages() where cachedSystemPrompt was
read without holding the lock in a debug log statement.
- Add tests: single system message invariant, mtime auto-invalidation,
new-file creation detection, skill file content change, explicit
InvalidateCache, cache stability, concurrent access (20 goroutines
x 50 iterations, passes go test -race), and a benchmark.
2026-02-25 02:34:54 +00:00
|
|
|
// openaiMessage is the wire-format message for OpenAI-compatible APIs.
|
|
|
|
|
// It mirrors protocoltypes.Message but omits SystemParts, which is an
|
|
|
|
|
// internal field that would be unknown to third-party endpoints.
|
|
|
|
|
type openaiMessage struct {
|
2026-03-01 08:23:05 +00:00
|
|
|
Role string `json:"role"`
|
|
|
|
|
Content string `json:"content"`
|
|
|
|
|
ReasoningContent string `json:"reasoning_content,omitempty"`
|
|
|
|
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
|
|
|
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
fix: cache system prompt with mtime-based auto-invalidation (#607)
Avoid rebuilding the entire system prompt on every BuildMessages() call
by caching the static portion (identity, bootstrap, skills summary,
memory) and only recomputing it when workspace source files change.
Key changes:
- ContextBuilder caches the static prompt behind an RWMutex with
double-checked locking. Source file changes are detected via cheap
os.Stat mtime checks so no explicit invalidation is needed.
- Track file existence at cache time (existedAtCache map) so that
newly created or deleted bootstrap/memory files also trigger a
rebuild — the old modifiedSince() silently returned false on
os.IsNotExist.
- Walk the skills directory recursively with filepath.WalkDir to
catch content-only edits at any nesting depth; directory mtime
alone misses in-place file modifications on most filesystems.
- ToolRegistry.sortedToolNames() sorts tool names before iteration,
ensuring deterministic tool definition order across calls — a
prerequisite for LLM-side prefix/KV cache reuse.
- Merge all context (static + dynamic + summary) into a single
system message for provider compatibility: the Anthropic adapter
extracts messages[0] as the top-level system parameter, and Codex
reads only the first system message as instructions.
- Fix a data race in BuildMessages() where cachedSystemPrompt was
read without holding the lock in a debug log statement.
- Add tests: single system message invariant, mtime auto-invalidation,
new-file creation detection, skill file content change, explicit
InvalidateCache, cache stability, concurrent access (20 goroutines
x 50 iterations, passes go test -race), and a benchmark.
2026-02-25 02:34:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// stripSystemParts converts []Message to []openaiMessage, dropping the
|
|
|
|
|
// SystemParts field so it doesn't leak into the JSON payload sent to
|
|
|
|
|
// OpenAI-compatible APIs (some strict endpoints reject unknown fields).
|
|
|
|
|
func stripSystemParts(messages []Message) []openaiMessage {
|
|
|
|
|
out := make([]openaiMessage, len(messages))
|
|
|
|
|
for i, m := range messages {
|
|
|
|
|
out[i] = openaiMessage{
|
2026-03-01 08:23:05 +00:00
|
|
|
Role: m.Role,
|
|
|
|
|
Content: m.Content,
|
|
|
|
|
ReasoningContent: m.ReasoningContent,
|
|
|
|
|
ToolCalls: m.ToolCalls,
|
|
|
|
|
ToolCallID: m.ToolCallID,
|
fix: cache system prompt with mtime-based auto-invalidation (#607)
Avoid rebuilding the entire system prompt on every BuildMessages() call
by caching the static portion (identity, bootstrap, skills summary,
memory) and only recomputing it when workspace source files change.
Key changes:
- ContextBuilder caches the static prompt behind an RWMutex with
double-checked locking. Source file changes are detected via cheap
os.Stat mtime checks so no explicit invalidation is needed.
- Track file existence at cache time (existedAtCache map) so that
newly created or deleted bootstrap/memory files also trigger a
rebuild — the old modifiedSince() silently returned false on
os.IsNotExist.
- Walk the skills directory recursively with filepath.WalkDir to
catch content-only edits at any nesting depth; directory mtime
alone misses in-place file modifications on most filesystems.
- ToolRegistry.sortedToolNames() sorts tool names before iteration,
ensuring deterministic tool definition order across calls — a
prerequisite for LLM-side prefix/KV cache reuse.
- Merge all context (static + dynamic + summary) into a single
system message for provider compatibility: the Anthropic adapter
extracts messages[0] as the top-level system parameter, and Codex
reads only the first system message as instructions.
- Fix a data race in BuildMessages() where cachedSystemPrompt was
read without holding the lock in a debug log statement.
- Add tests: single system message invariant, mtime auto-invalidation,
new-file creation detection, skill file content change, explicit
InvalidateCache, cache stability, concurrent access (20 goroutines
x 50 iterations, passes go test -race), and a benchmark.
2026-02-25 02:34:54 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 16:13:10 +00:00
|
|
|
func normalizeModel(model, apiBase string) string {
|
2026-02-27 08:35:07 +00:00
|
|
|
before, after, ok := strings.Cut(model, "/")
|
|
|
|
|
if !ok {
|
2026-02-17 16:13:10 +00:00
|
|
|
return model
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if strings.Contains(strings.ToLower(apiBase), "openrouter.ai") {
|
|
|
|
|
return model
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 08:35:07 +00:00
|
|
|
prefix := strings.ToLower(before)
|
2026-02-17 16:13:10 +00:00
|
|
|
switch prefix {
|
2026-02-20 15:31:35 +00:00
|
|
|
case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "mistral":
|
2026-02-27 08:35:07 +00:00
|
|
|
return after
|
2026-02-17 16:13:10 +00:00
|
|
|
default:
|
|
|
|
|
return model
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 18:03:11 +00:00
|
|
|
func asInt(v any) (int, bool) {
|
2026-02-17 16:13:10 +00:00
|
|
|
switch val := v.(type) {
|
|
|
|
|
case int:
|
|
|
|
|
return val, true
|
|
|
|
|
case int64:
|
|
|
|
|
return int(val), true
|
|
|
|
|
case float64:
|
|
|
|
|
return int(val), true
|
|
|
|
|
case float32:
|
|
|
|
|
return int(val), true
|
|
|
|
|
default:
|
|
|
|
|
return 0, false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 18:03:11 +00:00
|
|
|
func asFloat(v any) (float64, bool) {
|
2026-02-17 16:13:10 +00:00
|
|
|
switch val := v.(type) {
|
|
|
|
|
case float64:
|
|
|
|
|
return val, true
|
|
|
|
|
case float32:
|
|
|
|
|
return float64(val), true
|
|
|
|
|
case int:
|
|
|
|
|
return float64(val), true
|
|
|
|
|
case int64:
|
|
|
|
|
return float64(val), true
|
|
|
|
|
default:
|
|
|
|
|
return 0, false
|
|
|
|
|
}
|
|
|
|
|
}
|