on main help sign in
Taciturnaxolotl/custom anthropic providers (#300)
8c87429Kujtim Hoxha
README.md+40−4
@@ -135,7 +135,6 @@ Crush supports Model Context Protocol (MCP) servers through three transport type
135### Logging
135### Logging
136
136
137Enable debug logging with the `-d` flag or in config. View logs with `crush logs`. Logs are stored in `.crush/logs/crush.log`.
137Enable debug logging with the `-d` flag or in config. View logs with `crush logs`. Logs are stored in `.crush/logs/crush.log`.
138−
139```bash
138```bash
140# Run with debug logging
139# Run with debug logging
141crush -d
140crush -d
@@ -186,16 +185,21 @@ The `allowed_tools` array accepts:
186
185
187You can also skip all permission prompts entirely by running Crush with the `--yolo` flag.
186You can also skip all permission prompts entirely by running Crush with the `--yolo` flag.
188
187
188+### Custom Providers
189+
190+Crush supports custom provider configurations for both OpenAI-compatible and Anthropic-compatible APIs.
191+
189−### OpenAI-Compatible APIs
192+#### OpenAI-Compatible APIs
190
193
191−Crush supports all OpenAI-compatible APIs. Here's an example configuration for Deepseek, which uses an OpenAI-compatible API. Don't forget to set `DEEPSEEK_API_KEY` in your environment.
194+Here's an example configuration for Deepseek, which uses an OpenAI-compatible API. Don't forget to set `DEEPSEEK_API_KEY` in your environment.
192
195
193```json
196```json
194{
197{
195 "providers": {
198 "providers": {
196 "deepseek": {
199 "deepseek": {
197− "provider_type": "openai",
200+ "type": "openai",
198 "base_url": "https://api.deepseek.com/v1",
201 "base_url": "https://api.deepseek.com/v1",
202+ "api_key": "$DEEPSEEK_API_KEY",
199 "models": [
203 "models": [
200 {
204 {
201 "id": "deepseek-chat",
205 "id": "deepseek-chat",
@@ -213,6 +217,38 @@ Crush supports all OpenAI-compatible APIs. Here's an example configuration for D
213}
217}
214```
218```
215
219
220+#### Anthropic-Compatible APIs
221+
222+You can also configure custom Anthropic-compatible providers:
223+
224+```json
225+{
226+ "providers": {
227+ "custom-anthropic": {
228+ "type": "anthropic",
229+ "base_url": "https://api.anthropic.com/v1",
230+ "api_key": "$ANTHROPIC_API_KEY",
231+ "extra_headers": {
232+ "anthropic-version": "2023-06-01"
233+ },
234+ "models": [
235+ {
236+ "id": "claude-3-sonnet",
237+ "model": "Claude 3 Sonnet",
238+ "cost_per_1m_in": 3000,
239+ "cost_per_1m_out": 15000,
240+ "cost_per_1m_in_cached": 300,
241+ "cost_per_1m_out_cached": 15000,
242+ "context_window": 200000,
243+ "default_max_tokens": 4096,
244+ "supports_attachments": true
245+ }
246+ ]
247+ }
248+ }
249+}
250+```
251+
216## Whatcha think?
252## Whatcha think?
217
253
218We’d love to hear your thoughts on this project. Feel free to drop us a note!
254We’d love to hear your thoughts on this project. Feel free to drop us a note!
internal/config/config.go+3
@@ -77,6 +77,9 @@ type ProviderConfig struct {
77 // Marks the provider as disabled.
77 // Marks the provider as disabled.
78 Disable bool `json:"disable,omitempty"`
78 Disable bool `json:"disable,omitempty"`
79
79
80+ // Custom system prompt prefix.
81+ SystemPromptPrefix string `json:"system_prompt_prefix,omitempty"`
82+
80 // Extra headers to send with each request to the provider.
83 // Extra headers to send with each request to the provider.
81 ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
84 ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
82 // Extra body
85 // Extra body
internal/config/load.go+1−1
@@ -232,7 +232,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
232 c.Providers.Del(id)
232 c.Providers.Del(id)
233 continue
233 continue
234 }
234 }
235− if providerConfig.Type != catwalk.TypeOpenAI {
235+ if providerConfig.Type != catwalk.TypeOpenAI && providerConfig.Type != catwalk.TypeAnthropic {
236 slog.Warn("Skipping custom provider because the provider type is not supported", "provider", id, "type", providerConfig.Type)
236 slog.Warn("Skipping custom provider because the provider type is not supported", "provider", id, "type", providerConfig.Type)
237 c.Providers.Del(id)
237 c.Providers.Del(id)
238 continue
238 continue
internal/config/load_test.go+29
@@ -613,6 +613,35 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
613 assert.Equal(t, "https://api.custom.com/v1", customProvider.BaseURL)
613 assert.Equal(t, "https://api.custom.com/v1", customProvider.BaseURL)
614 })
614 })
615
615
616+ t.Run("custom anthropic provider is supported", func(t *testing.T) {
617+ cfg := &Config{
618+ Providers: map[string]ProviderConfig{
619+ "custom-anthropic": {
620+ APIKey: "test-key",
621+ BaseURL: "https://api.anthropic.com/v1",
622+ Type: catwalk.TypeAnthropic,
623+ Models: []catwalk.Model{{
624+ ID: "claude-3-sonnet",
625+ }},
626+ },
627+ },
628+ }
629+ cfg.setDefaults("/tmp")
630+
631+ env := env.NewFromMap(map[string]string{})
632+ resolver := NewEnvironmentVariableResolver(env)
633+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
634+ assert.NoError(t, err)
635+
636+ assert.Len(t, cfg.Providers, 1)
637+ customProvider, exists := cfg.Providers["custom-anthropic"]
638+ assert.True(t, exists)
639+ assert.Equal(t, "custom-anthropic", customProvider.ID)
640+ assert.Equal(t, "test-key", customProvider.APIKey)
641+ assert.Equal(t, "https://api.anthropic.com/v1", customProvider.BaseURL)
642+ assert.Equal(t, catwalk.TypeAnthropic, customProvider.Type)
643+ })
644+
616 t.Run("disabled custom provider is removed", func(t *testing.T) {
645 t.Run("disabled custom provider is removed", func(t *testing.T) {
617 cfg := &Config{
646 cfg := &Config{
618 Providers: csync.NewMapFrom(map[string]ProviderConfig{
647 Providers: csync.NewMapFrom(map[string]ProviderConfig{
internal/config/resolve.go+100−14
@@ -35,34 +35,120 @@ func NewShellVariableResolver(env env.Env) VariableResolver {
35}
35}
36
36
37// ResolveValue is a method for resolving values, such as environment variables.
37// ResolveValue is a method for resolving values, such as environment variables.
38−// it will expect strings that start with `$` to be resolved as environment variables or shell commands.
38+// it will resolve shell-like variable substitution anywhere in the string, including:
39−// if the string does not start with `$`, it will return the string as is.
39+// - $(command) for command substitution
40+// - $VAR or ${VAR} for environment variables
40func (r *shellVariableResolver) ResolveValue(value string) (string, error) {
41func (r *shellVariableResolver) ResolveValue(value string) (string, error) {
42+ // Special case: lone $ is an error (backward compatibility)
43+ if value == "$" {
44+ return "", fmt.Errorf("invalid value format: %s", value)
45+ }
46+
47+ // If no $ found, return as-is
41− if !strings.HasPrefix(value, "$") {
48+ if !strings.Contains(value, "$") {
42 return value, nil
49 return value, nil
43 }
50 }
44
51
45− if strings.HasPrefix(value, "$(") && strings.HasSuffix(value, ")") {
52+ result := value
46− command := strings.TrimSuffix(strings.TrimPrefix(value, "$("), ")")
53+
54+ // Handle command substitution: $(command)
55+ for {
56+ start := strings.Index(result, "$(")
57+ if start == -1 {
58+ break
59+ }
60+
61+ // Find matching closing parenthesis
62+ depth := 0
63+ end := -1
64+ for i := start + 2; i < len(result); i++ {
65+ if result[i] == '(' {
66+ depth++
67+ } else if result[i] == ')' {
68+ if depth == 0 {
69+ end = i
70+ break
71+ }
72+ depth--
73+ }
74+ }
75+
76+ if end == -1 {
77+ return "", fmt.Errorf("unmatched $( in value: %s", value)
78+ }
79+
80+ command := result[start+2 : end]
47 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
81 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
48− defer cancel()
49
82
50 stdout, _, err := r.shell.Exec(ctx, command)
83 stdout, _, err := r.shell.Exec(ctx, command)
84+ cancel()
51 if err != nil {
85 if err != nil {
52− return "", fmt.Errorf("command execution failed: %w", err)
86+ return "", fmt.Errorf("command execution failed for '%s': %w", command, err)
53 }
87 }
88+
89+ // Replace the $(command) with the output
54− return strings.TrimSpace(stdout), nil
90+ replacement := strings.TrimSpace(stdout)
91+ result = result[:start] + replacement + result[end+1:]
55 }
92 }
56
93
57− if after, ok := strings.CutPrefix(value, "$"); ok {
94+ // Handle environment variables: $VAR and ${VAR}
95+ searchStart := 0
96+ for {
97+ start := strings.Index(result[searchStart:], "$")
98+ if start == -1 {
99+ break
100+ }
101+ start += searchStart // Adjust for the offset
102+
103+ // Skip if this is part of $( which we already handled
104+ if start+1 < len(result) && result[start+1] == '(' {
105+ // Skip past this $(...)
106+ searchStart = start + 1
107+ continue
108+ }
58− varName := after
109+ var varName string
110+ var end int
111+
112+ if start+1 < len(result) && result[start+1] == '{' {
113+ // Handle ${VAR} format
114+ closeIdx := strings.Index(result[start+2:], "}")
115+ if closeIdx == -1 {
116+ return "", fmt.Errorf("unmatched ${ in value: %s", value)
117+ }
118+ varName = result[start+2 : start+2+closeIdx]
119+ end = start + 2 + closeIdx + 1
120+ } else {
121+ // Handle $VAR format - variable names must start with letter or underscore
122+ if start+1 >= len(result) {
123+ return "", fmt.Errorf("incomplete variable reference at end of string: %s", value)
124+ }
125+
126+ if result[start+1] != '_' &&
127+ (result[start+1] < 'a' || result[start+1] > 'z') &&
128+ (result[start+1] < 'A' || result[start+1] > 'Z') {
129+ return "", fmt.Errorf("invalid variable name starting with '%c' in: %s", result[start+1], value)
130+ }
131+
132+ end = start + 1
133+ for end < len(result) && (result[end] == '_' ||
134+ (result[end] >= 'a' && result[end] <= 'z') ||
135+ (result[end] >= 'A' && result[end] <= 'Z') ||
136+ (result[end] >= '0' && result[end] <= '9')) {
137+ end++
138+ }
139+ varName = result[start+1 : end]
140+ }
141+
59− value = r.env.Get(varName)
142+ envValue := r.env.Get(varName)
60− if value == "" {
143+ if envValue == "" {
61 return "", fmt.Errorf("environment variable %q not set", varName)
144 return "", fmt.Errorf("environment variable %q not set", varName)
62 }
145 }
63− return value, nil
146+
147+ result = result[:start] + envValue + result[end:]
148+ searchStart = start + len(envValue) // Continue searching after the replacement
64 }
149 }
65− return "", fmt.Errorf("invalid value format: %s", value)
150+
151+ return result, nil
66}
152}
67
153
68type environmentVariableResolver struct {
154type environmentVariableResolver struct {
internal/config/resolve_test.go+166−11
@@ -47,17 +47,7 @@ func TestShellVariableResolver_ResolveValue(t *testing.T) {
47 envVars: map[string]string{},
47 envVars: map[string]string{},
48 expectError: true,
48 expectError: true,
49 },
49 },
50− {
50+
51− name: "shell command execution",
52− value: "$(echo hello)",
53− shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
54− if command == "echo hello" {
55− return "hello\n", "", nil
56− }
57− return "", "", errors.New("unexpected command")
58− },
59− expected: "hello",
60− },
61 {
51 {
62 name: "shell command with whitespace trimming",
52 name: "shell command with whitespace trimming",
63 value: "$(echo ' spaced ')",
53 value: "$(echo ' spaced ')",
@@ -104,6 +94,171 @@ func TestShellVariableResolver_ResolveValue(t *testing.T) {
104 }
94 }
105}
95}
106
96
97+func TestShellVariableResolver_EnhancedResolveValue(t *testing.T) {
98+ tests := []struct {
99+ name string
100+ value string
101+ envVars map[string]string
102+ shellFunc func(ctx context.Context, command string) (stdout, stderr string, err error)
103+ expected string
104+ expectError bool
105+ }{
106+ {
107+ name: "command substitution within string",
108+ value: "Bearer $(echo token123)",
109+ shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
110+ if command == "echo token123" {
111+ return "token123\n", "", nil
112+ }
113+ return "", "", errors.New("unexpected command")
114+ },
115+ expected: "Bearer token123",
116+ },
117+ {
118+ name: "environment variable within string",
119+ value: "Bearer $TOKEN",
120+ envVars: map[string]string{"TOKEN": "sk-ant-123"},
121+ expected: "Bearer sk-ant-123",
122+ },
123+ {
124+ name: "environment variable with braces within string",
125+ value: "Bearer ${TOKEN}",
126+ envVars: map[string]string{"TOKEN": "sk-ant-456"},
127+ expected: "Bearer sk-ant-456",
128+ },
129+ {
130+ name: "mixed command and environment substitution",
131+ value: "$USER-$(date +%Y)-$HOST",
132+ envVars: map[string]string{
133+ "USER": "testuser",
134+ "HOST": "localhost",
135+ },
136+ shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
137+ if command == "date +%Y" {
138+ return "2024\n", "", nil
139+ }
140+ return "", "", errors.New("unexpected command")
141+ },
142+ expected: "testuser-2024-localhost",
143+ },
144+ {
145+ name: "multiple command substitutions",
146+ value: "$(echo hello) $(echo world)",
147+ shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
148+ switch command {
149+ case "echo hello":
150+ return "hello\n", "", nil
151+ case "echo world":
152+ return "world\n", "", nil
153+ }
154+ return "", "", errors.New("unexpected command")
155+ },
156+ expected: "hello world",
157+ },
158+ {
159+ name: "nested parentheses in command",
160+ value: "$(echo $(echo inner))",
161+ shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
162+ if command == "echo $(echo inner)" {
163+ return "nested\n", "", nil
164+ }
165+ return "", "", errors.New("unexpected command")
166+ },
167+ expected: "nested",
168+ },
169+ {
170+ name: "lone dollar with non-variable chars",
171+ value: "prefix$123suffix", // Numbers can't start variable names
172+ expectError: true,
173+ },
174+ {
175+ name: "dollar with special chars",
176+ value: "a$@b$#c", // Special chars aren't valid in variable names
177+ expectError: true,
178+ },
179+ {
180+ name: "empty environment variable substitution",
181+ value: "Bearer $EMPTY_VAR",
182+ envVars: map[string]string{},
183+ expectError: true,
184+ },
185+ {
186+ name: "unmatched command substitution opening",
187+ value: "Bearer $(echo test",
188+ expectError: true,
189+ },
190+ {
191+ name: "unmatched environment variable braces",
192+ value: "Bearer ${TOKEN",
193+ expectError: true,
194+ },
195+ {
196+ name: "command substitution with error",
197+ value: "Bearer $(false)",
198+ shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
199+ return "", "", errors.New("command failed")
200+ },
201+ expectError: true,
202+ },
203+ {
204+ name: "complex real-world example",
205+ value: "Bearer $(cat /tmp/token.txt | base64 -w 0)",
206+ shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
207+ if command == "cat /tmp/token.txt | base64 -w 0" {
208+ return "c2stYW50LXRlc3Q=\n", "", nil
209+ }
210+ return "", "", errors.New("unexpected command")
211+ },
212+ expected: "Bearer c2stYW50LXRlc3Q=",
213+ },
214+ {
215+ name: "environment variable with underscores and numbers",
216+ value: "Bearer $API_KEY_V2",
217+ envVars: map[string]string{"API_KEY_V2": "sk-test-123"},
218+ expected: "Bearer sk-test-123",
219+ },
220+ {
221+ name: "no substitution needed",
222+ value: "Bearer sk-ant-static-token",
223+ expected: "Bearer sk-ant-static-token",
224+ },
225+ {
226+ name: "incomplete variable at end",
227+ value: "Bearer $",
228+ expectError: true,
229+ },
230+ {
231+ name: "variable with invalid character",
232+ value: "Bearer $VAR-NAME", // Hyphen not allowed in variable names
233+ expectError: true,
234+ },
235+ {
236+ name: "multiple invalid variables",
237+ value: "$1$2$3",
238+ expectError: true,
239+ },
240+ }
241+
242+ for _, tt := range tests {
243+ t.Run(tt.name, func(t *testing.T) {
244+ testEnv := env.NewFromMap(tt.envVars)
245+ resolver := &shellVariableResolver{
246+ shell: &mockShell{execFunc: tt.shellFunc},
247+ env: testEnv,
248+ }
249+
250+ result, err := resolver.ResolveValue(tt.value)
251+
252+ if tt.expectError {
253+ assert.Error(t, err)
254+ } else {
255+ assert.NoError(t, err)
256+ assert.Equal(t, tt.expected, result)
257+ }
258+ })
259+ }
260+}
261+
107func TestEnvironmentVariableResolver_ResolveValue(t *testing.T) {
262func TestEnvironmentVariableResolver_ResolveValue(t *testing.T) {
108 tests := []struct {
263 tests := []struct {
109 name string
264 name string
internal/llm/provider/anthropic.go+45−10
@@ -39,8 +39,30 @@ func newAnthropicClient(opts providerClientOptions, useBedrock bool) AnthropicCl
39
39
40func createAnthropicClient(opts providerClientOptions, useBedrock bool) anthropic.Client {
40func createAnthropicClient(opts providerClientOptions, useBedrock bool) anthropic.Client {
41 anthropicClientOptions := []option.RequestOption{}
41 anthropicClientOptions := []option.RequestOption{}
42− if opts.apiKey != "" {
42+
43+ // Check if Authorization header is provided in extra headers
44+ hasBearerAuth := false
45+ if opts.extraHeaders != nil {
46+ for key := range opts.extraHeaders {
47+ if strings.ToLower(key) == "authorization" {
48+ hasBearerAuth = true
49+ break
50+ }
51+ }
52+ }
53+
54+ isBearerToken := strings.HasPrefix(opts.apiKey, "Bearer ")
55+
56+ if opts.apiKey != "" && !hasBearerAuth {
57+ if isBearerToken {
58+ slog.Debug("API key starts with 'Bearer ', using as Authorization header")
59+ anthropicClientOptions = append(anthropicClientOptions, option.WithHeader("Authorization", opts.apiKey))
60+ } else {
61+ // Use standard X-Api-Key header
43− anthropicClientOptions = append(anthropicClientOptions, option.WithAPIKey(opts.apiKey))
62+ anthropicClientOptions = append(anthropicClientOptions, option.WithAPIKey(opts.apiKey))
63+ }
64+ } else if hasBearerAuth {
65+ slog.Debug("Skipping X-Api-Key header because Authorization header is provided")
44 }
66 }
45 if useBedrock {
67 if useBedrock {
46 anthropicClientOptions = append(anthropicClientOptions, bedrock.WithLoadDefaultConfig(context.Background()))
68 anthropicClientOptions = append(anthropicClientOptions, bedrock.WithLoadDefaultConfig(context.Background()))
@@ -200,6 +222,25 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
200 maxTokens = int64(a.adjustedMaxTokens)
222 maxTokens = int64(a.adjustedMaxTokens)
201 }
223 }
202
224
225+ systemBlocks := []anthropic.TextBlockParam{}
226+
227+ // Add custom system prompt prefix if configured
228+ if a.providerOptions.systemPromptPrefix != "" {
229+ systemBlocks = append(systemBlocks, anthropic.TextBlockParam{
230+ Text: a.providerOptions.systemPromptPrefix,
231+ CacheControl: anthropic.CacheControlEphemeralParam{
232+ Type: "ephemeral",
233+ },
234+ })
235+ }
236+
237+ systemBlocks = append(systemBlocks, anthropic.TextBlockParam{
238+ Text: a.providerOptions.systemMessage,
239+ CacheControl: anthropic.CacheControlEphemeralParam{
240+ Type: "ephemeral",
241+ },
242+ })
243+
203 return anthropic.MessageNewParams{
244 return anthropic.MessageNewParams{
204 Model: anthropic.Model(model.ID),
245 Model: anthropic.Model(model.ID),
205 MaxTokens: maxTokens,
246 MaxTokens: maxTokens,
@@ -207,14 +248,7 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
207 Messages: messages,
248 Messages: messages,
208 Tools: tools,
249 Tools: tools,
209 Thinking: thinkingParam,
250 Thinking: thinkingParam,
210− System: []anthropic.TextBlockParam{
251+ System: systemBlocks,
211− {
212− Text: a.providerOptions.systemMessage,
213− CacheControl: anthropic.CacheControlEphemeralParam{
214− Type: "ephemeral",
215− },
216− },
217− },
218 }
252 }
219}
253}
220
254
@@ -393,6 +427,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
393 close(eventChan)
427 close(eventChan)
394 return
428 return
395 }
429 }
430+
396 // If there is an error we are going to see if we can retry the call
431 // If there is an error we are going to see if we can retry the call
397 retry, after, retryErr := a.shouldRetry(attempts, err)
432 retry, after, retryErr := a.shouldRetry(attempts, err)
398 if retryErr != nil {
433 if retryErr != nil {
internal/llm/provider/gemini.go+10−2
@@ -180,12 +180,16 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
180 if modelConfig.MaxTokens > 0 {
180 if modelConfig.MaxTokens > 0 {
181 maxTokens = modelConfig.MaxTokens
181 maxTokens = modelConfig.MaxTokens
182 }
182 }
183+ systemMessage := g.providerOptions.systemMessage
184+ if g.providerOptions.systemPromptPrefix != "" {
185+ systemMessage = g.providerOptions.systemPromptPrefix + "\n" + systemMessage
186+ }
183 history := geminiMessages[:len(geminiMessages)-1] // All but last message
187 history := geminiMessages[:len(geminiMessages)-1] // All but last message
184 lastMsg := geminiMessages[len(geminiMessages)-1]
188 lastMsg := geminiMessages[len(geminiMessages)-1]
185 config := &genai.GenerateContentConfig{
189 config := &genai.GenerateContentConfig{
186 MaxOutputTokens: int32(maxTokens),
190 MaxOutputTokens: int32(maxTokens),
187 SystemInstruction: &genai.Content{
191 SystemInstruction: &genai.Content{
188− Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
192+ Parts: []*genai.Part{{Text: systemMessage}},
189 },
193 },
190 }
194 }
191 config.Tools = g.convertTools(tools)
195 config.Tools = g.convertTools(tools)
@@ -280,12 +284,16 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
280 if g.providerOptions.maxTokens > 0 {
284 if g.providerOptions.maxTokens > 0 {
281 maxTokens = g.providerOptions.maxTokens
285 maxTokens = g.providerOptions.maxTokens
282 }
286 }
287+ systemMessage := g.providerOptions.systemMessage
288+ if g.providerOptions.systemPromptPrefix != "" {
289+ systemMessage = g.providerOptions.systemPromptPrefix + "\n" + systemMessage
290+ }
283 history := geminiMessages[:len(geminiMessages)-1] // All but last message
291 history := geminiMessages[:len(geminiMessages)-1] // All but last message
284 lastMsg := geminiMessages[len(geminiMessages)-1]
292 lastMsg := geminiMessages[len(geminiMessages)-1]
285 config := &genai.GenerateContentConfig{
293 config := &genai.GenerateContentConfig{
286 MaxOutputTokens: int32(maxTokens),
294 MaxOutputTokens: int32(maxTokens),
287 SystemInstruction: &genai.Content{
295 SystemInstruction: &genai.Content{
288− Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
296+ Parts: []*genai.Part{{Text: systemMessage}},
289 },
297 },
290 }
298 }
291 config.Tools = g.convertTools(tools)
299 config.Tools = g.convertTools(tools)
internal/llm/provider/openai.go+5−1
@@ -57,7 +57,11 @@ func createOpenAIClient(opts providerClientOptions) openai.Client {
57
57
58func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) {
58func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) {
59 // Add system message first
59 // Add system message first
60+ systemMessage := o.providerOptions.systemMessage
61+ if o.providerOptions.systemPromptPrefix != "" {
62+ systemMessage = o.providerOptions.systemPromptPrefix + "\n" + systemMessage
63+ }
60− openaiMessages = append(openaiMessages, openai.SystemMessage(o.providerOptions.systemMessage))
64+ openaiMessages = append(openaiMessages, openai.SystemMessage(systemMessage))
61
65
62 for _, msg := range messages {
66 for _, msg := range messages {
63 switch msg.Role {
67 switch msg.Role {
internal/llm/provider/provider.go+28−16
@@ -61,17 +61,18 @@ type Provider interface {
61}
61}
62
62
63type providerClientOptions struct {
63type providerClientOptions struct {
64− baseURL string
64+ baseURL string
65− config config.ProviderConfig
65+ config config.ProviderConfig
66− apiKey string
66+ apiKey string
67− modelType config.SelectedModelType
67+ modelType config.SelectedModelType
68− model func(config.SelectedModelType) catwalk.Model
68+ model func(config.SelectedModelType) catwalk.Model
69− disableCache bool
69+ disableCache bool
70− systemMessage string
70+ systemMessage string
71+ systemPromptPrefix string
71− maxTokens int64
72+ maxTokens int64
72− extraHeaders map[string]string
73+ extraHeaders map[string]string
73− extraBody map[string]any
74+ extraBody map[string]any
74− extraParams map[string]string
75+ extraParams map[string]string
75}
76}
76
77
77type ProviderClientOption func(*providerClientOptions)
78type ProviderClientOption func(*providerClientOptions)
@@ -143,12 +144,23 @@ func NewProvider(cfg config.ProviderConfig, opts ...ProviderClientOption) (Provi
143 return nil, fmt.Errorf("failed to resolve API key for provider %s: %w", cfg.ID, err)
144 return nil, fmt.Errorf("failed to resolve API key for provider %s: %w", cfg.ID, err)
144 }
145 }
145
146
147+ // Resolve extra headers
148+ resolvedExtraHeaders := make(map[string]string)
149+ for key, value := range cfg.ExtraHeaders {
150+ resolvedValue, err := config.Get().Resolve(value)
151+ if err != nil {
152+ return nil, fmt.Errorf("failed to resolve extra header %s for provider %s: %w", key, cfg.ID, err)
153+ }
154+ resolvedExtraHeaders[key] = resolvedValue
155+ }
156+
146 clientOptions := providerClientOptions{
157 clientOptions := providerClientOptions{
147− baseURL: cfg.BaseURL,
158+ baseURL: cfg.BaseURL,
148− config: cfg,
159+ config: cfg,
149− apiKey: resolvedAPIKey,
160+ apiKey: resolvedAPIKey,
150− extraHeaders: cfg.ExtraHeaders,
161+ extraHeaders: resolvedExtraHeaders,
151− extraBody: cfg.ExtraBody,
162+ extraBody: cfg.ExtraBody,
163+ systemPromptPrefix: cfg.SystemPromptPrefix,
152 model: func(tp config.SelectedModelType) catwalk.Model {
164 model: func(tp config.SelectedModelType) catwalk.Model {
153 return *config.Get().GetModelByType(tp)
165 return *config.Get().GetModelByType(tp)
154 },
166 },