openai_adapter.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675
  1. package workflow
  2. import (
  3. "bufio"
  4. "bytes"
  5. "context"
  6. "encoding/json"
  7. "fmt"
  8. "io"
  9. "net/http"
  10. "strings"
  11. "time"
  12. )
  13. // OpenAIAdapter implements LLMAdapter for OpenAI-compatible server
  14. type OpenAIAdapter struct {
  15. baseURL string
  16. apiKey string
  17. model string
  18. cacheControl *bool
  19. requestAPIKey string
  20. httpClient *http.Client
  21. }
  22. // OpenAIConfig holds configuration for OpenAI-compatible adapter
  23. type OpenAIConfig struct {
  24. BaseURL string // OpenAI-compatible server URL (e.g., "http://localhost:4000")
  25. APIKey string // Optional API key for Authorization header
  26. Model string // Optional model override (overwrites model in LLM requests)
  27. CacheControl *bool // Optional cache_control override (overwrites cache_control in LLM requests)
  28. RequestAPIKey string // Optional API key sent in request body as "api_key" (for BYOK passthrough)
  29. Timeout time.Duration // HTTP timeout (default: 5 minutes)
  30. }
  31. // NewOpenAIAdapter creates a new OpenAI-compatible adapter
  32. func NewOpenAIAdapter(config OpenAIConfig) *OpenAIAdapter {
  33. timeout := config.Timeout
  34. if timeout == 0 {
  35. timeout = 5 * time.Minute
  36. }
  37. baseURL := strings.TrimSuffix(config.BaseURL, "/")
  38. if baseURL == "" {
  39. baseURL = "http://localhost:4000"
  40. }
  41. return &OpenAIAdapter{
  42. baseURL: baseURL,
  43. apiKey: config.APIKey,
  44. model: config.Model,
  45. cacheControl: config.CacheControl,
  46. requestAPIKey: config.RequestAPIKey,
  47. httpClient: &http.Client{
  48. Timeout: timeout,
  49. },
  50. }
  51. }
  52. // ResponseFormat represents the response format configuration (OpenAI style)
  53. type ResponseFormat struct {
  54. Type string `json:"type"` // "json_schema" or "text"
  55. JSONSchema *JSONSchema `json:"json_schema,omitempty"` // Only when type is "json_schema"
  56. }
  57. // JSONSchema represents the JSON Schema configuration
  58. type JSONSchema struct {
  59. Name string `json:"name"` // Schema name identifier
  60. Description string `json:"description,omitempty"` // Optional description
  61. Schema map[string]interface{} `json:"schema"` // JSON Schema object
  62. Strict bool `json:"strict,omitempty"` // Optional strict mode
  63. }
  64. // OutputConfig represents the output configuration (Anthropic style)
  65. type OutputConfig struct {
  66. Format *OutputFormat `json:"format,omitempty"`
  67. }
  68. // OutputFormat represents the format configuration for Anthropic
  69. type OutputFormat struct {
  70. Type string `json:"type"` // "json_schema" or "text"
  71. Schema map[string]interface{} `json:"schema,omitempty"` // JSON Schema object (when type is "json_schema")
  72. }
  73. // ChatCompletionRequest represents the OpenAI-compatible chat request
  74. type ChatCompletionRequest struct {
  75. Model string `json:"model"`
  76. Messages []ChatMessage `json:"messages"`
  77. Stream bool `json:"stream,omitempty"`
  78. Temperature *float64 `json:"temperature,omitempty"`
  79. MaxTokens *int `json:"max_tokens,omitempty"`
  80. TopP *float64 `json:"top_p,omitempty"`
  81. Stop []string `json:"stop,omitempty"`
  82. Tools []map[string]interface{} `json:"tools,omitempty"`
  83. APIKey string `json:"api_key,omitempty"`
  84. ResponseFormat *ResponseFormat `json:"response_format,omitempty"` // OpenAI style
  85. OutputConfig *OutputConfig `json:"output_config,omitempty"` // Anthropic style
  86. }
  87. // CacheControl represents Anthropic's cache control directive
  88. type CacheControl struct {
  89. Type string `json:"type"`
  90. }
  91. // ChatMessage represents a message in the chat
  92. type ChatMessage struct {
  93. Role string `json:"role"`
  94. Content string `json:"content"`
  95. CacheControl *CacheControl `json:"cache_control,omitempty"`
  96. }
  97. // ChatCompletionResponse represents the OpenAI-compatible chat response
  98. type ChatCompletionResponse struct {
  99. ID string `json:"id"`
  100. Object string `json:"object"`
  101. Created int64 `json:"created"`
  102. Model string `json:"model"`
  103. Choices []struct {
  104. Index int `json:"index"`
  105. Message struct {
  106. Role string `json:"role"`
  107. Content string `json:"content"`
  108. } `json:"message"`
  109. FinishReason string `json:"finish_reason"`
  110. } `json:"choices"`
  111. Usage struct {
  112. PromptTokens int `json:"prompt_tokens"`
  113. CompletionTokens int `json:"completion_tokens"`
  114. TotalTokens int `json:"total_tokens"`
  115. } `json:"usage"`
  116. }
  117. // ChatCompletionStreamResponse represents a streaming response chunk
  118. type ChatCompletionStreamResponse struct {
  119. ID string `json:"id"`
  120. Object string `json:"object"`
  121. Created int64 `json:"created"`
  122. Model string `json:"model"`
  123. Choices []struct {
  124. Index int `json:"index"`
  125. Delta struct {
  126. Role string `json:"role,omitempty"`
  127. Content string `json:"content,omitempty"`
  128. } `json:"delta"`
  129. FinishReason *string `json:"finish_reason"`
  130. } `json:"choices"`
  131. }
  132. // Call implements LLMAdapter interface
  133. func (a *OpenAIAdapter) Call(ctx context.Context, params map[string]interface{}, stream chan<- string) (map[string]interface{}, error) {
  134. // Build request from params
  135. req, err := a.buildRequest(params)
  136. if err != nil {
  137. return nil, fmt.Errorf("failed to build request: %w", err)
  138. }
  139. // Check if streaming is requested
  140. isStreaming := false
  141. if streamVal, ok := params["stream"].(bool); ok {
  142. isStreaming = streamVal
  143. }
  144. req.Stream = isStreaming
  145. // Make the request
  146. var result map[string]interface{}
  147. if isStreaming && stream != nil {
  148. result, err = a.callStreaming(ctx, req, stream)
  149. } else {
  150. result, err = a.callNonStreaming(ctx, req)
  151. }
  152. if err != nil {
  153. return nil, err
  154. }
  155. // Check if structured output (json_schema) was requested
  156. // Check both response_format (OpenAI) and output_config (Anthropic) styles
  157. shouldParseJSON := false
  158. // Check response_format (OpenAI style)
  159. if responseFormat, ok := params["response_format"].(map[string]interface{}); ok {
  160. if formatType, ok := responseFormat["type"].(string); ok && formatType == "json_schema" {
  161. shouldParseJSON = true
  162. }
  163. }
  164. // Check output_config (Anthropic style)
  165. if outputConfig, ok := params["output_config"].(map[string]interface{}); ok {
  166. if format, ok := outputConfig["format"].(map[string]interface{}); ok {
  167. if formatType, ok := format["type"].(string); ok && formatType == "json_schema" {
  168. shouldParseJSON = true
  169. }
  170. }
  171. }
  172. // Parse content as JSON if structured output was requested
  173. if shouldParseJSON {
  174. if content, ok := result["content"].(string); ok && content != "" {
  175. var parsed interface{}
  176. if err := json.Unmarshal([]byte(content), &parsed); err != nil {
  177. model, _ := result["model"].(string)
  178. return nil, &LLMError{
  179. Type: "json_parse_error",
  180. Code: "JSON_PARSE_ERROR",
  181. Message: fmt.Sprintf("failed to parse structured output as JSON: %v", err),
  182. Retryable: false,
  183. Model: model,
  184. }
  185. }
  186. // Replace the content string with the parsed JSON object
  187. result["content"] = parsed
  188. }
  189. }
  190. return result, nil
  191. }
  192. // buildRequest constructs a ChatCompletionRequest from params
  193. func (a *OpenAIAdapter) buildRequest(params map[string]interface{}) (*ChatCompletionRequest, error) {
  194. req := &ChatCompletionRequest{}
  195. // API key in request body for BYOK passthrough
  196. if a.requestAPIKey != "" {
  197. req.APIKey = a.requestAPIKey
  198. }
  199. // Model: adapter-level override takes priority, then params, then default
  200. // Note: In v3.9+, model should not be specified in workflow JSON - it should be
  201. // injected at runtime via adapter configuration (a.model). The params["model"]
  202. // fallback is kept for backward compatibility with v3.6/v3.7/v3.8 workflows.
  203. if a.model != "" {
  204. req.Model = a.model
  205. } else if model, ok := params["model"].(string); ok {
  206. req.Model = model
  207. } else {
  208. req.Model = "gpt-4" // default model
  209. }
  210. // Cache control: adapter-level override takes priority, then params
  211. cacheControl := false
  212. if a.cacheControl != nil {
  213. cacheControl = *a.cacheControl
  214. } else if cc, ok := params["cache_control"].(bool); ok {
  215. cacheControl = cc
  216. }
  217. // Messages (required)
  218. if messages, ok := params["messages"].([]interface{}); ok {
  219. for _, m := range messages {
  220. msg, ok := m.(map[string]interface{})
  221. if !ok {
  222. continue
  223. }
  224. chatMsg := ChatMessage{}
  225. if role, ok := msg["role"].(string); ok {
  226. chatMsg.Role = role
  227. }
  228. if content, ok := msg["content"].(string); ok {
  229. chatMsg.Content = content
  230. }
  231. // Apply cache_control to system messages for Anthropic models
  232. if cacheControl && chatMsg.Role == "system" && isAnthropicModel(req.Model) {
  233. chatMsg.CacheControl = &CacheControl{Type: "ephemeral"}
  234. }
  235. req.Messages = append(req.Messages, chatMsg)
  236. }
  237. }
  238. // Optional parameters
  239. if temp, ok := params["temperature"].(float64); ok {
  240. req.Temperature = &temp
  241. }
  242. if maxTokens, ok := params["max_tokens"].(int); ok {
  243. req.MaxTokens = &maxTokens
  244. } else if maxTokens, ok := params["max_tokens"].(float64); ok {
  245. mt := int(maxTokens)
  246. req.MaxTokens = &mt
  247. }
  248. if topP, ok := params["top_p"].(float64); ok {
  249. req.TopP = &topP
  250. }
  251. if stop, ok := params["stop"].([]interface{}); ok {
  252. for _, s := range stop {
  253. if str, ok := s.(string); ok {
  254. req.Stop = append(req.Stop, str)
  255. }
  256. }
  257. }
  258. if tools, ok := params["tools"].([]interface{}); ok {
  259. for _, t := range tools {
  260. if tool, ok := t.(map[string]interface{}); ok {
  261. req.Tools = append(req.Tools, tool)
  262. }
  263. }
  264. }
  265. // Handle response_format (vendor-agnostic to vendor-specific mapping)
  266. if responseFormat, ok := params["response_format"].(map[string]interface{}); ok {
  267. if err := a.applyResponseFormat(req, responseFormat); err != nil {
  268. return nil, fmt.Errorf("failed to apply response_format: %w", err)
  269. }
  270. }
  271. // Handle output_config (Anthropic style - direct passthrough)
  272. // This takes precedence over response_format mapping if both are present
  273. if outputConfig, ok := params["output_config"].(map[string]interface{}); ok {
  274. if err := a.applyOutputConfig(req, outputConfig); err != nil {
  275. return nil, fmt.Errorf("failed to apply output_config: %w", err)
  276. }
  277. }
  278. return req, nil
  279. }
  280. // applyResponseFormat maps vendor-agnostic response_format to vendor-specific format
  281. func (a *OpenAIAdapter) applyResponseFormat(req *ChatCompletionRequest, responseFormat map[string]interface{}) error {
  282. // Extract type field
  283. formatType, ok := responseFormat["type"].(string)
  284. if !ok {
  285. return fmt.Errorf("response_format.type must be a string")
  286. }
  287. // If type is "text", no need to set anything (default behavior)
  288. if formatType == "text" {
  289. return nil
  290. }
  291. // Handle "json_schema" type
  292. if formatType == "json_schema" {
  293. // Extract json_schema object
  294. jsonSchemaRaw, ok := responseFormat["json_schema"].(map[string]interface{})
  295. if !ok {
  296. return fmt.Errorf("response_format.json_schema must be an object when type is json_schema")
  297. }
  298. // Build JSONSchema struct
  299. jsonSchema := &JSONSchema{}
  300. if name, ok := jsonSchemaRaw["name"].(string); ok {
  301. jsonSchema.Name = name
  302. } else {
  303. return fmt.Errorf("response_format.json_schema.name is required")
  304. }
  305. if desc, ok := jsonSchemaRaw["description"].(string); ok {
  306. jsonSchema.Description = desc
  307. }
  308. if schema, ok := jsonSchemaRaw["schema"].(map[string]interface{}); ok {
  309. jsonSchema.Schema = schema
  310. } else {
  311. return fmt.Errorf("response_format.json_schema.schema is required")
  312. }
  313. if strict, ok := jsonSchemaRaw["strict"].(bool); ok {
  314. jsonSchema.Strict = strict
  315. }
  316. // Determine vendor and apply appropriate format
  317. if isAnthropicModel(req.Model) {
  318. // Anthropic uses output_config with schema directly in format
  319. req.OutputConfig = &OutputConfig{
  320. Format: &OutputFormat{
  321. Type: "json_schema",
  322. Schema: jsonSchema.Schema,
  323. },
  324. }
  325. } else {
  326. // OpenAI and others use response_format with json_schema wrapper
  327. req.ResponseFormat = &ResponseFormat{
  328. Type: "json_schema",
  329. JSONSchema: jsonSchema,
  330. }
  331. }
  332. }
  333. return nil
  334. }
  335. // applyOutputConfig applies output_config directly from params (Anthropic style)
  336. // This is a direct passthrough - the output_config structure in the workflow JSON
  337. // must match the target LLM vendor's API format exactly
  338. func (a *OpenAIAdapter) applyOutputConfig(req *ChatCompletionRequest, outputConfig map[string]interface{}) error {
  339. // Extract format field
  340. format, ok := outputConfig["format"].(map[string]interface{})
  341. if !ok {
  342. return fmt.Errorf("output_config.format must be an object")
  343. }
  344. // Extract type field
  345. formatType, ok := format["type"].(string)
  346. if !ok {
  347. return fmt.Errorf("output_config.format.type must be a string")
  348. }
  349. // If type is "text", no need to set anything (default behavior)
  350. if formatType == "text" {
  351. return nil
  352. }
  353. // Handle "json_schema" type
  354. if formatType == "json_schema" {
  355. // Extract schema object
  356. schemaRaw, ok := format["schema"].(map[string]interface{})
  357. if !ok {
  358. // Provide more detailed error information
  359. schemaVal, exists := format["schema"]
  360. if !exists {
  361. return fmt.Errorf("output_config.format.schema is required when type is json_schema (field is missing)")
  362. }
  363. return fmt.Errorf("output_config.format.schema must be an object when type is json_schema (got %T: %v)", schemaVal, schemaVal)
  364. }
  365. // For Anthropic models, use output_config structure with schema directly
  366. req.OutputConfig = &OutputConfig{
  367. Format: &OutputFormat{
  368. Type: "json_schema",
  369. Schema: schemaRaw,
  370. },
  371. }
  372. }
  373. return nil
  374. }
  375. // callNonStreaming makes a non-streaming request
  376. func (a *OpenAIAdapter) callNonStreaming(ctx context.Context, req *ChatCompletionRequest) (map[string]interface{}, error) {
  377. req.Stream = false
  378. body, err := json.Marshal(req)
  379. if err != nil {
  380. return nil, fmt.Errorf("failed to marshal request: %w", err)
  381. }
  382. httpReq, err := http.NewRequestWithContext(ctx, "POST", a.baseURL+"/chat/completions", bytes.NewReader(body))
  383. if err != nil {
  384. return nil, fmt.Errorf("failed to create request: %w", err)
  385. }
  386. httpReq.Header.Set("Content-Type", "application/json")
  387. if a.apiKey != "" {
  388. httpReq.Header.Set("Authorization", "Bearer "+a.apiKey)
  389. }
  390. resp, err := a.httpClient.Do(httpReq)
  391. if err != nil {
  392. return nil, fmt.Errorf("failed to send request: %w", err)
  393. }
  394. defer resp.Body.Close()
  395. if resp.StatusCode != http.StatusOK {
  396. bodyBytes, _ := io.ReadAll(resp.Body)
  397. return nil, parseOpenAIError(resp.StatusCode, bodyBytes, req.Model)
  398. }
  399. var chatResp ChatCompletionResponse
  400. if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
  401. return nil, fmt.Errorf("failed to decode response: %w", err)
  402. }
  403. // Extract content from response
  404. content := ""
  405. if len(chatResp.Choices) > 0 {
  406. content = chatResp.Choices[0].Message.Content
  407. }
  408. return map[string]interface{}{
  409. "content": content,
  410. "model": chatResp.Model,
  411. "finish_reason": getFinishReason(chatResp.Choices),
  412. "response_id": chatResp.ID,
  413. "usage": map[string]interface{}{
  414. "prompt_tokens": chatResp.Usage.PromptTokens,
  415. "completion_tokens": chatResp.Usage.CompletionTokens,
  416. "total_tokens": chatResp.Usage.TotalTokens,
  417. },
  418. }, nil
  419. }
  420. // callStreaming makes a streaming request
  421. func (a *OpenAIAdapter) callStreaming(ctx context.Context, req *ChatCompletionRequest, stream chan<- string) (map[string]interface{}, error) {
  422. req.Stream = true
  423. body, err := json.Marshal(req)
  424. if err != nil {
  425. return nil, fmt.Errorf("failed to marshal request: %w", err)
  426. }
  427. httpReq, err := http.NewRequestWithContext(ctx, "POST", a.baseURL+"/chat/completions", bytes.NewReader(body))
  428. if err != nil {
  429. return nil, fmt.Errorf("failed to create request: %w", err)
  430. }
  431. httpReq.Header.Set("Content-Type", "application/json")
  432. httpReq.Header.Set("Accept", "text/event-stream")
  433. if a.apiKey != "" {
  434. httpReq.Header.Set("Authorization", "Bearer "+a.apiKey)
  435. }
  436. resp, err := a.httpClient.Do(httpReq)
  437. if err != nil {
  438. return nil, fmt.Errorf("failed to send request: %w", err)
  439. }
  440. defer resp.Body.Close()
  441. if resp.StatusCode != http.StatusOK {
  442. bodyBytes, _ := io.ReadAll(resp.Body)
  443. return nil, parseOpenAIError(resp.StatusCode, bodyBytes, req.Model)
  444. }
  445. // Process SSE stream
  446. var fullContent strings.Builder
  447. var finishReason string
  448. var model string
  449. var responseID string
  450. scanner := bufio.NewScanner(resp.Body)
  451. // Increase buffer size for large SSE chunks (1MB max)
  452. scanner.Buffer(make([]byte, 64*1024), 1024*1024)
  453. for scanner.Scan() {
  454. line := scanner.Text()
  455. // Skip empty lines and comments
  456. if line == "" || strings.HasPrefix(line, ":") {
  457. continue
  458. }
  459. // Parse SSE data
  460. if !strings.HasPrefix(line, "data: ") {
  461. continue
  462. }
  463. data := strings.TrimPrefix(line, "data: ")
  464. // Check for end of stream
  465. if data == "[DONE]" {
  466. break
  467. }
  468. var chunk ChatCompletionStreamResponse
  469. if err := json.Unmarshal([]byte(data), &chunk); err != nil {
  470. continue // Skip malformed chunks
  471. }
  472. if model == "" && chunk.Model != "" {
  473. model = chunk.Model
  474. }
  475. if responseID == "" && chunk.ID != "" {
  476. responseID = chunk.ID
  477. }
  478. // Extract content from chunk
  479. if len(chunk.Choices) > 0 {
  480. delta := chunk.Choices[0].Delta
  481. if delta.Content != "" {
  482. fullContent.WriteString(delta.Content)
  483. // Send chunk to stream channel
  484. select {
  485. case stream <- delta.Content:
  486. case <-ctx.Done():
  487. return nil, ctx.Err()
  488. }
  489. }
  490. if chunk.Choices[0].FinishReason != nil {
  491. finishReason = *chunk.Choices[0].FinishReason
  492. }
  493. }
  494. }
  495. if err := scanner.Err(); err != nil {
  496. return nil, fmt.Errorf("error reading stream: %w", err)
  497. }
  498. return map[string]interface{}{
  499. "content": fullContent.String(),
  500. "model": model,
  501. "finish_reason": finishReason,
  502. "response_id": responseID,
  503. }, nil
  504. }
  505. // isAnthropicModel checks if the model string refers to an Anthropic model
  506. func isAnthropicModel(model string) bool {
  507. m := strings.ToLower(model)
  508. return strings.HasPrefix(m, "claude") ||
  509. strings.HasPrefix(m, "anthropic/") ||
  510. strings.Contains(m, "anthropic")
  511. }
  512. // parseOpenAIError constructs a structured LLMError from an HTTP error response
  513. func parseOpenAIError(statusCode int, body []byte, model string) *LLMError {
  514. llmErr := &LLMError{
  515. StatusCode: statusCode,
  516. Model: model,
  517. Message: string(body),
  518. }
  519. // Try to parse body as JSON for richer error info
  520. var errResp map[string]interface{}
  521. if json.Unmarshal(body, &errResp) == nil {
  522. llmErr.Raw = errResp
  523. if msg, ok := errResp["message"].(string); ok {
  524. llmErr.Message = msg
  525. }
  526. if errObj, ok := errResp["error"].(map[string]interface{}); ok {
  527. if msg, ok := errObj["message"].(string); ok {
  528. llmErr.Message = msg
  529. }
  530. if code, ok := errObj["code"].(string); ok {
  531. llmErr.Code = code
  532. }
  533. if errType, ok := errObj["type"].(string); ok {
  534. llmErr.ProviderError = errType
  535. }
  536. }
  537. }
  538. // Classify error type based on status code
  539. switch {
  540. case statusCode == 401 || statusCode == 403:
  541. llmErr.Type = "auth_error"
  542. llmErr.Code = "AUTH_ERROR"
  543. llmErr.Retryable = false
  544. case statusCode == 400:
  545. llmErr.Type = "bad_request"
  546. llmErr.Code = "BAD_REQUEST"
  547. llmErr.Retryable = false
  548. case statusCode == 429:
  549. llmErr.Type = "rate_limit"
  550. llmErr.Code = "RATE_LIMIT"
  551. llmErr.Retryable = true
  552. case statusCode == 408:
  553. llmErr.Type = "timeout"
  554. llmErr.Code = "TIMEOUT"
  555. llmErr.Retryable = true
  556. case statusCode >= 500:
  557. llmErr.Type = "service_unavailable"
  558. llmErr.Code = "SERVICE_UNAVAILABLE"
  559. llmErr.Retryable = true
  560. default:
  561. llmErr.Type = "unknown_error"
  562. llmErr.Code = "UNKNOWN_ERROR"
  563. llmErr.Retryable = false
  564. }
  565. // Refine classification based on error message content
  566. msgLower := strings.ToLower(llmErr.Message)
  567. if strings.Contains(msgLower, "context length") || strings.Contains(msgLower, "token limit") {
  568. llmErr.Type = "context_length_exceeded"
  569. llmErr.Code = "CONTEXT_LENGTH_EXCEEDED"
  570. llmErr.Retryable = false
  571. }
  572. if strings.Contains(msgLower, "content policy") || strings.Contains(msgLower, "content_filter") {
  573. llmErr.Type = "content_policy_violation"
  574. llmErr.Code = "CONTENT_POLICY_VIOLATION"
  575. llmErr.Retryable = false
  576. }
  577. // Infer provider from model name
  578. llmErr.Provider = inferProvider(model)
  579. return llmErr
  580. }
  581. func getFinishReason(choices []struct {
  582. Index int `json:"index"`
  583. Message struct {
  584. Role string `json:"role"`
  585. Content string `json:"content"`
  586. } `json:"message"`
  587. FinishReason string `json:"finish_reason"`
  588. }) string {
  589. if len(choices) > 0 {
  590. return choices[0].FinishReason
  591. }
  592. return ""
  593. }