package compat import ( "bufio" "bytes" "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net" "net/http" "regexp" "slices" "strings" "sync" "time" thoughttypes "git.warky.dev/wdevs/amcs/internal/types" ) const metadataSystemPrompt = `You extract structured metadata from short notes. Return only valid JSON matching this schema: { "people": ["string"], "action_items": ["string"], "dates_mentioned": ["string"], "topics": ["string"], "type": "observation|task|idea|reference|person_note", "source": "string" } Rules: - Keep arrays concise. - Use lowercase for type. - If unsure, prefer "observation". - Do not include any text outside the JSON object.` type Client struct { name string baseURL string apiKey string embeddingModel string metadataModel string fallbackMetadataModels []string temperature float64 headers map[string]string httpClient *http.Client log *slog.Logger dimensions int logConversations bool modelHealthMu sync.Mutex modelHealth map[string]modelHealthState } type Config struct { Name string BaseURL string APIKey string EmbeddingModel string MetadataModel string FallbackMetadataModels []string Temperature float64 Headers map[string]string HTTPClient *http.Client Log *slog.Logger Dimensions int LogConversations bool } type embeddingsRequest struct { Input string `json:"input"` Model string `json:"model"` } type embeddingsResponse struct { Data []struct { Embedding []float32 `json:"embedding"` } `json:"data"` Error *providerError `json:"error,omitempty"` } type chatCompletionsRequest struct { Model string `json:"model"` Temperature float64 `json:"temperature,omitempty"` ResponseFormat *responseType `json:"response_format,omitempty"` Stream *bool `json:"stream,omitempty"` Messages []chatMessage `json:"messages"` } type responseType struct { Type string `json:"type"` } type chatMessage struct { Role string `json:"role"` Content string `json:"content"` } type chatCompletionsResponse struct { Choices []struct { Message responseChatMessage `json:"message"` Text string `json:"text,omitempty"` } `json:"choices"` Error *providerError `json:"error,omitempty"` } type chatCompletionsChunk struct { Choices []struct { Delta responseChatMessage `json:"delta"` Message responseChatMessage `json:"message"` Text string `json:"text,omitempty"` } `json:"choices"` Error *providerError `json:"error,omitempty"` } type responseChatMessage struct { Role string `json:"role"` Content json.RawMessage `json:"content"` ReasoningContent string `json:"reasoning_content,omitempty"` } type providerError struct { Message string `json:"message"` Type string `json:"type,omitempty"` } const maxMetadataAttempts = 3 const ( emptyResponseCircuitThreshold = 3 emptyResponseCircuitTTL = 5 * time.Minute permanentModelFailureTTL = 24 * time.Hour ) var ( errMetadataEmptyResponse = errors.New("metadata empty response") errMetadataNoJSONObject = errors.New("metadata response contains no JSON object") ) type modelHealthState struct { consecutiveEmpty int unhealthyUntil time.Time } func New(cfg Config) *Client { fallbacks := make([]string, 0, len(cfg.FallbackMetadataModels)) seen := make(map[string]struct{}, len(cfg.FallbackMetadataModels)) for _, model := range cfg.FallbackMetadataModels { model = strings.TrimSpace(model) if model == "" { continue } if _, ok := seen[model]; ok { continue } seen[model] = struct{}{} fallbacks = append(fallbacks, model) } return &Client{ name: cfg.Name, baseURL: cfg.BaseURL, apiKey: cfg.APIKey, embeddingModel: cfg.EmbeddingModel, metadataModel: cfg.MetadataModel, fallbackMetadataModels: fallbacks, temperature: cfg.Temperature, headers: cfg.Headers, httpClient: cfg.HTTPClient, log: cfg.Log, dimensions: cfg.Dimensions, logConversations: cfg.LogConversations, modelHealth: make(map[string]modelHealthState), } } func (c *Client) Embed(ctx context.Context, input string) ([]float32, error) { input = strings.TrimSpace(input) if input == "" { return nil, fmt.Errorf("%s embed: input must not be empty", c.name) } var resp embeddingsResponse err := c.doJSON(ctx, "/embeddings", embeddingsRequest{ Input: input, Model: c.embeddingModel, }, &resp) if err != nil { return nil, err } if resp.Error != nil { return nil, fmt.Errorf("%s embed error: %s", c.name, resp.Error.Message) } if len(resp.Data) == 0 { return nil, fmt.Errorf("%s embed: no embedding returned", c.name) } if c.dimensions > 0 && len(resp.Data[0].Embedding) != c.dimensions { return nil, fmt.Errorf("%s embed: expected %d dimensions, got %d", c.name, c.dimensions, len(resp.Data[0].Embedding)) } return resp.Data[0].Embedding, nil } func (c *Client) ExtractMetadata(ctx context.Context, input string) (thoughttypes.ThoughtMetadata, error) { input = strings.TrimSpace(input) if input == "" { return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s extract metadata: input must not be empty", c.name) } result, err := c.extractMetadataWithModel(ctx, input, c.metadataModel) if errors.Is(err, errMetadataEmptyResponse) { c.noteEmptyResponse(c.metadataModel) } if isPermanentModelError(err) { c.notePermanentModelFailure(c.metadataModel, err) } if err == nil { c.noteModelSuccess(c.metadataModel) return result, nil } for _, fallbackModel := range c.fallbackMetadataModels { if ctx.Err() != nil { break } if fallbackModel == "" || fallbackModel == c.metadataModel { continue } if c.shouldBypassModel(fallbackModel) { continue } if c.log != nil { c.log.Warn("metadata extraction failed, trying fallback model", slog.String("provider", c.name), slog.String("primary_model", c.metadataModel), slog.String("fallback_model", fallbackModel), slog.String("error", err.Error()), ) } fallbackResult, fallbackErr := c.extractMetadataWithModel(ctx, input, fallbackModel) if errors.Is(fallbackErr, errMetadataEmptyResponse) { c.noteEmptyResponse(fallbackModel) } if isPermanentModelError(fallbackErr) { c.notePermanentModelFailure(fallbackModel, fallbackErr) } if fallbackErr == nil { c.noteModelSuccess(fallbackModel) return fallbackResult, nil } err = fallbackErr } heuristic := heuristicMetadataFromInput(input) if c.log != nil { c.log.Warn("metadata extraction failed for all models, using heuristic fallback", slog.String("provider", c.name), slog.String("error", err.Error()), ) } return heuristic, nil } func (c *Client) extractMetadataWithModel(ctx context.Context, input, model string) (thoughttypes.ThoughtMetadata, error) { if c.shouldBypassModel(model) { return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s metadata: model %q temporarily bypassed after repeated empty responses", c.name, model) } stream := true req := chatCompletionsRequest{ Model: model, Temperature: c.temperature, ResponseFormat: &responseType{ Type: "json_object", }, Stream: &stream, Messages: []chatMessage{ {Role: "system", Content: metadataSystemPrompt}, {Role: "user", Content: input}, }, } metadata, err := c.extractMetadataWithRequest(ctx, req, input, model) if err == nil || !shouldRetryWithoutJSONMode(err) { return metadata, err } if c.log != nil { c.log.Warn("metadata json mode failed, retrying without response_format", slog.String("provider", c.name), slog.String("model", model), slog.String("error", err.Error()), ) } req.ResponseFormat = nil return c.extractMetadataWithRequest(ctx, req, input, model) } func (c *Client) extractMetadataWithRequest(ctx context.Context, req chatCompletionsRequest, input, model string) (thoughttypes.ThoughtMetadata, error) { var lastErr error for attempt := 1; attempt <= maxMetadataAttempts; attempt++ { if c.logConversations && c.log != nil { c.log.Info("metadata conversation request", slog.String("provider", c.name), slog.String("model", model), slog.Int("attempt", attempt), slog.String("system", metadataSystemPrompt), slog.String("input", input), ) } resp, err := c.doChatCompletions(ctx, req) if err != nil { return thoughttypes.ThoughtMetadata{}, err } if resp.Error != nil { return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s metadata error: %s", c.name, resp.Error.Message) } if len(resp.Choices) == 0 { return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s metadata: no choices returned", c.name) } rawResponse := extractChoiceText(resp.Choices[0].Message, resp.Choices[0].Text) if c.logConversations && c.log != nil { c.log.Info("metadata conversation response", slog.String("provider", c.name), slog.String("model", model), slog.Int("attempt", attempt), slog.String("response", rawResponse), ) } metadataText := strings.TrimSpace(rawResponse) metadataText = stripThinkingBlocks(metadataText) metadataText = stripCodeFence(metadataText) metadataText = extractJSONObject(metadataText) if metadataText == "" { lastErr = fmt.Errorf("%s metadata: %w", c.name, errMetadataNoJSONObject) if strings.TrimSpace(rawResponse) == "" && attempt < maxMetadataAttempts && ctx.Err() == nil { lastErr = fmt.Errorf("%s metadata: %w", c.name, errMetadataEmptyResponse) if c.log != nil { c.log.Warn("metadata response empty, waiting and retrying", slog.String("provider", c.name), slog.String("model", model), slog.Int("attempt", attempt+1), ) } if err := sleepMetadataRetry(ctx, attempt); err != nil { return thoughttypes.ThoughtMetadata{}, err } continue } if strings.TrimSpace(rawResponse) == "" { lastErr = fmt.Errorf("%s metadata: %w", c.name, errMetadataEmptyResponse) } return thoughttypes.ThoughtMetadata{}, lastErr } var metadata thoughttypes.ThoughtMetadata if err := json.Unmarshal([]byte(metadataText), &metadata); err != nil { lastErr = fmt.Errorf("%s metadata: parse json: %w", c.name, err) return thoughttypes.ThoughtMetadata{}, lastErr } return metadata, nil } if lastErr != nil { return thoughttypes.ThoughtMetadata{}, lastErr } return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s metadata: %w", c.name, errMetadataNoJSONObject) } func (c *Client) Summarize(ctx context.Context, systemPrompt, userPrompt string) (string, error) { req := chatCompletionsRequest{ Model: c.metadataModel, Temperature: 0.2, Messages: []chatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: userPrompt}, }, } var resp chatCompletionsResponse if err := c.doJSON(ctx, "/chat/completions", req, &resp); err != nil { return "", err } if resp.Error != nil { return "", fmt.Errorf("%s summarize error: %s", c.name, resp.Error.Message) } if len(resp.Choices) == 0 { return "", fmt.Errorf("%s summarize: no choices returned", c.name) } return extractChoiceText(resp.Choices[0].Message, resp.Choices[0].Text), nil } func (c *Client) Name() string { return c.name } func (c *Client) EmbeddingModel() string { return c.embeddingModel } func (c *Client) doJSON(ctx context.Context, path string, requestBody any, dest any) error { body, err := json.Marshal(requestBody) if err != nil { return fmt.Errorf("%s request marshal: %w", c.name, err) } const maxAttempts = 3 var lastErr error for attempt := 1; attempt <= maxAttempts; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.baseURL, "/")+path, bytes.NewReader(body)) if err != nil { return fmt.Errorf("%s build request: %w", c.name, err) } req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") for key, value := range c.headers { if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" { continue } req.Header.Set(key, value) } resp, err := c.httpClient.Do(req) if err != nil { lastErr = fmt.Errorf("%s request failed: %w", c.name, err) if attempt < maxAttempts && ctx.Err() == nil && isRetryableError(err) { if retryErr := sleepRetry(ctx, attempt, c.log, c.name); retryErr != nil { return retryErr } continue } return lastErr } payload, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { lastErr = fmt.Errorf("%s read response: %w", c.name, readErr) if attempt < maxAttempts { if retryErr := sleepRetry(ctx, attempt, c.log, c.name); retryErr != nil { return retryErr } continue } return lastErr } if resp.StatusCode >= http.StatusBadRequest { lastErr = fmt.Errorf("%s request failed with status %d: %s", c.name, resp.StatusCode, strings.TrimSpace(string(payload))) if attempt < maxAttempts && isRetryableStatus(resp.StatusCode) { if retryErr := sleepRetry(ctx, attempt, c.log, c.name); retryErr != nil { return retryErr } continue } return lastErr } if err := json.Unmarshal(payload, dest); err != nil { if c.log != nil { c.log.Debug("provider response body", slog.String("provider", c.name), slog.String("body", string(payload))) } return fmt.Errorf("%s decode response: %w", c.name, err) } return nil } return lastErr } func (c *Client) doChatCompletions(ctx context.Context, reqBody chatCompletionsRequest) (chatCompletionsResponse, error) { var resp chatCompletionsResponse body, err := json.Marshal(reqBody) if err != nil { return resp, fmt.Errorf("%s request marshal: %w", c.name, err) } const maxAttempts = 3 var lastErr error for attempt := 1; attempt <= maxAttempts; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.baseURL, "/")+"/chat/completions", bytes.NewReader(body)) if err != nil { return resp, fmt.Errorf("%s build request: %w", c.name, err) } req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") for key, value := range c.headers { if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" { continue } req.Header.Set(key, value) } httpResp, err := c.httpClient.Do(req) if err != nil { lastErr = fmt.Errorf("%s request failed: %w", c.name, err) if attempt < maxAttempts && ctx.Err() == nil && isRetryableError(err) { if retryErr := sleepRetry(ctx, attempt, c.log, c.name); retryErr != nil { return resp, retryErr } continue } return resp, lastErr } resp, err = c.decodeChatCompletionsResponse(httpResp) if err == nil { return resp, nil } lastErr = err if attempt < maxAttempts && (ctx.Err() == nil) && isRetryableChatResponseError(err) { if retryErr := sleepRetry(ctx, attempt, c.log, c.name); retryErr != nil { return resp, retryErr } continue } return resp, lastErr } return resp, lastErr } func (c *Client) decodeChatCompletionsResponse(resp *http.Response) (chatCompletionsResponse, error) { defer resp.Body.Close() contentType := strings.ToLower(resp.Header.Get("Content-Type")) if strings.Contains(contentType, "text/event-stream") { streamResp, err := decodeStreamingChatCompletionsResponse(resp.Body) if err != nil { return chatCompletionsResponse{}, fmt.Errorf("%s read stream response: %w", c.name, err) } if resp.StatusCode >= http.StatusBadRequest { if streamResp.Error != nil { return chatCompletionsResponse{}, fmt.Errorf("%s request failed with status %d: %s", c.name, resp.StatusCode, streamResp.Error.Message) } return chatCompletionsResponse{}, fmt.Errorf("%s request failed with status %d", c.name, resp.StatusCode) } return streamResp, nil } payload, readErr := io.ReadAll(resp.Body) if readErr != nil { return chatCompletionsResponse{}, fmt.Errorf("%s read response: %w", c.name, readErr) } if resp.StatusCode >= http.StatusBadRequest { return chatCompletionsResponse{}, fmt.Errorf("%s request failed with status %d: %s", c.name, resp.StatusCode, strings.TrimSpace(string(payload))) } var decoded chatCompletionsResponse if err := json.Unmarshal(payload, &decoded); err != nil { if c.log != nil { c.log.Debug("provider response body", slog.String("provider", c.name), slog.String("body", string(payload))) } return chatCompletionsResponse{}, fmt.Errorf("%s decode response: %w", c.name, err) } return decoded, nil } func decodeStreamingChatCompletionsResponse(body io.Reader) (chatCompletionsResponse, error) { scanner := bufio.NewScanner(body) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) var eventLines []string var textBuilder strings.Builder var lastMessage responseChatMessage var streamErr *providerError flushEvent := func() error { if len(eventLines) == 0 { return nil } payload := strings.Join(eventLines, "\n") eventLines = eventLines[:0] payload = strings.TrimSpace(payload) if payload == "" { return nil } if payload == "[DONE]" { return nil } var chunk chatCompletionsChunk if err := json.Unmarshal([]byte(payload), &chunk); err != nil { return err } if chunk.Error != nil { streamErr = chunk.Error } for _, choice := range chunk.Choices { if text := extractChoiceText(choice.Delta, choice.Text); text != "" { textBuilder.WriteString(text) lastMessage = choice.Delta continue } if text := extractChoiceText(choice.Message, choice.Text); text != "" { textBuilder.WriteString(text) lastMessage = choice.Message continue } if len(choice.Message.Content) > 0 || choice.Message.ReasoningContent != "" { lastMessage = choice.Message } } return nil } for scanner.Scan() { line := scanner.Text() if line == "" { if err := flushEvent(); err != nil { return chatCompletionsResponse{}, err } continue } if strings.HasPrefix(line, ":") { continue } if strings.HasPrefix(line, "data:") { eventLines = append(eventLines, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) } } if err := scanner.Err(); err != nil { return chatCompletionsResponse{}, err } if err := flushEvent(); err != nil { return chatCompletionsResponse{}, err } content := textBuilder.String() if content != "" || len(lastMessage.Content) > 0 || lastMessage.ReasoningContent != "" { encoded, _ := json.Marshal(content) lastMessage.Content = json.RawMessage(encoded) return chatCompletionsResponse{ Choices: []struct { Message responseChatMessage `json:"message"` Text string `json:"text,omitempty"` }{ {Message: lastMessage, Text: content}, }, Error: streamErr, }, nil } return chatCompletionsResponse{Error: streamErr}, nil } func isRetryableChatResponseError(err error) bool { if err == nil { return false } if isRetryableError(err) { return true } lower := strings.ToLower(err.Error()) return strings.Contains(lower, "read response") || strings.Contains(lower, "read stream response") } // extractJSONObject finds the first complete {...} block in s. // It handles models that prepend prose to a JSON response despite json_object mode. func extractJSONObject(s string) string { for start := 0; start < len(s); start++ { if s[start] != '{' { continue } depth := 0 inString := false escaped := false for end := start; end < len(s); end++ { ch := s[end] if escaped { escaped = false continue } if ch == '\\' && inString { escaped = true continue } if ch == '"' { inString = !inString continue } if inString { continue } switch ch { case '{': depth++ case '}': depth-- if depth == 0 { candidate := s[start : end+1] if json.Valid([]byte(candidate)) { return candidate } } } } } return "" } // stripThinkingBlocks removes ... and ... // blocks produced by reasoning models (DeepSeek R1, QwQ, etc.) so that the // remaining text can be parsed as JSON without interference from thinking content // that may itself contain braces. func stripThinkingBlocks(s string) string { for _, tag := range []string{"think", "thinking"} { open := "<" + tag + ">" close := "" for { start := strings.Index(s, open) if start == -1 { break } end := strings.Index(s[start:], close) if end == -1 { s = s[:start] break } s = s[:start] + s[start+end+len(close):] } } return strings.TrimSpace(s) } func stripCodeFence(value string) string { value = strings.TrimSpace(value) if !strings.HasPrefix(value, "```") { return value } value = strings.TrimPrefix(value, "```json") value = strings.TrimPrefix(value, "```") value = strings.TrimSuffix(value, "```") return strings.TrimSpace(value) } func extractChoiceText(message responseChatMessage, fallbackText string) string { if text := strings.TrimSpace(extractMessageContent(message.Content)); text != "" { return text } if text := strings.TrimSpace(message.ReasoningContent); text != "" { return text } return strings.TrimSpace(fallbackText) } func extractMessageContent(raw json.RawMessage) string { raw = bytes.TrimSpace(raw) if len(raw) == 0 { return "" } var contentString string if err := json.Unmarshal(raw, &contentString); err == nil { return contentString } var contentArray []any if err := json.Unmarshal(raw, &contentArray); err == nil { parts := make([]string, 0, len(contentArray)) for _, item := range contentArray { if text := strings.TrimSpace(extractTextFromAny(item)); text != "" { parts = append(parts, text) } } return strings.Join(parts, "\n") } var contentObject map[string]any if err := json.Unmarshal(raw, &contentObject); err == nil { return extractTextFromAny(contentObject) } return strings.TrimSpace(string(raw)) } func extractTextFromAny(value any) string { switch typed := value.(type) { case string: return typed case []any: parts := make([]string, 0, len(typed)) for _, item := range typed { if text := strings.TrimSpace(extractTextFromAny(item)); text != "" { parts = append(parts, text) } } return strings.Join(parts, "\n") case map[string]any: // Common provider shapes for chat content parts. for _, key := range []string{"text", "output_text", "content", "value"} { if nested, ok := typed[key]; ok { if text := strings.TrimSpace(extractTextFromAny(nested)); text != "" { return text } } } } return "" } var ( monthDatePattern = regexp.MustCompile(`(?i)\b\d{1,2}\s+(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+\d{4}\b`) isoDatePattern = regexp.MustCompile(`\b\d{4}-\d{2}-\d{2}\b`) wordPattern = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9_/-]{2,}`) ) func heuristicMetadataFromInput(input string) thoughttypes.ThoughtMetadata { text := strings.TrimSpace(input) lower := strings.ToLower(text) metadata := thoughttypes.ThoughtMetadata{ People: heuristicPeople(text), ActionItems: heuristicActionItems(text), DatesMentioned: heuristicDates(text), Topics: heuristicTopics(lower), Type: heuristicType(lower), Source: "", } if len(metadata.Topics) == 0 { metadata.Topics = []string{"uncategorized"} } if metadata.Type == "" { metadata.Type = "observation" } return metadata } func heuristicType(lower string) string { switch { case strings.Contains(lower, "preferred name"), strings.Contains(lower, "personal profile"), strings.Contains(lower, "wife:"), strings.Contains(lower, "daughter:"), strings.Contains(lower, "born:"): return "person_note" case strings.Contains(lower, "todo"), strings.Contains(lower, "action item"), strings.Contains(lower, "need to"), strings.Contains(lower, "must "), strings.Contains(lower, "should "): return "task" case strings.Contains(lower, "idea"), strings.Contains(lower, "proposal"), strings.Contains(lower, "brainstorm"): return "idea" case strings.Contains(lower, "reference"), strings.Contains(lower, "rfc "), strings.Contains(lower, "docs"), strings.Contains(lower, "spec"): return "reference" default: return "observation" } } func heuristicTopics(lower string) []string { candidates := []string{ "mcp", "auth", "oauth", "api_keys", "token", "middleware", "postgres", "search", "embeddings", "metadata", "go", "server", "project", "memory", "claude", "automation", "calendar", "email", "atlassian", "n8n", } topics := make([]string, 0, 6) for _, topic := range candidates { if strings.Contains(lower, topic) { topics = append(topics, topic) } if len(topics) >= 6 { break } } if len(topics) > 0 { return topics } words := wordPattern.FindAllString(lower, -1) for _, w := range words { if len(w) < 4 { continue } if slices.Contains(topics, w) { continue } topics = append(topics, w) if len(topics) >= 4 { break } } return topics } func heuristicDates(text string) []string { values := make([]string, 0, 4) seen := map[string]struct{}{} for _, match := range monthDatePattern.FindAllString(text, -1) { key := strings.ToLower(match) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} values = append(values, match) } for _, match := range isoDatePattern.FindAllString(text, -1) { key := strings.ToLower(match) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} values = append(values, match) } return values } func heuristicPeople(text string) []string { lines := strings.Split(text, "\n") people := make([]string, 0, 4) seen := map[string]struct{}{} add := func(name string) { name = strings.TrimSpace(name) if name == "" { return } key := strings.ToLower(name) if _, ok := seen[key]; ok { return } seen[key] = struct{}{} people = append(people, name) } for _, line := range lines { l := strings.TrimSpace(line) l = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(l, "-"), "*")) if strings.Contains(strings.ToLower(l), "preferred name") && strings.Contains(l, " is ") { parts := strings.SplitN(l, " is ", 2) add(parts[0]) } for _, marker := range []string{"Wife:", "Daughter:", "Son:", "Partner:", "Name:"} { if strings.HasPrefix(l, marker) { rest := strings.TrimSpace(strings.TrimPrefix(l, marker)) if idx := strings.Index(rest, ","); idx > 0 { rest = rest[:idx] } add(rest) } } } return people } func heuristicActionItems(text string) []string { lines := strings.Split(text, "\n") items := make([]string, 0, 5) for _, line := range lines { l := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(line, "-"), "*")) if l == "" { continue } ll := strings.ToLower(l) if strings.Contains(ll, "todo") || strings.HasPrefix(ll, "fix ") || strings.HasPrefix(ll, "add ") || strings.HasPrefix(ll, "update ") || strings.HasPrefix(ll, "implement ") { items = append(items, l) } if len(items) >= 5 { break } } return items } func isRetryableStatus(status int) bool { switch status { case http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: return true default: return false } } func isRetryableError(err error) bool { if err == nil { return false } if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { return true } var netErr net.Error return errors.As(err, &netErr) } func shouldRetryWithoutJSONMode(err error) bool { if err == nil { return false } if errors.Is(err, errMetadataEmptyResponse) || errors.Is(err, errMetadataNoJSONObject) { return true } lower := strings.ToLower(err.Error()) return strings.Contains(lower, "parse json") } func isPermanentModelError(err error) bool { if err == nil { return false } lower := strings.ToLower(err.Error()) for _, marker := range []string{ "invalid model name", "model_not_found", "model not found", "unknown model", "no such model", "does not exist", } { if strings.Contains(lower, marker) { return true } } return false } func sleepRetry(ctx context.Context, attempt int, log *slog.Logger, provider string) error { delay := time.Duration(attempt*attempt) * 200 * time.Millisecond if log != nil { log.Warn("retrying provider request", slog.String("provider", provider), slog.Duration("delay", delay), slog.Int("attempt", attempt+1)) } timer := time.NewTimer(delay) defer timer.Stop() select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } } func sleepMetadataRetry(ctx context.Context, attempt int) error { delay := time.Duration(attempt) * 350 * time.Millisecond timer := time.NewTimer(delay) defer timer.Stop() select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } } func (c *Client) shouldBypassModel(model string) bool { c.modelHealthMu.Lock() defer c.modelHealthMu.Unlock() state, ok := c.modelHealth[model] if !ok { return false } return !state.unhealthyUntil.IsZero() && time.Now().Before(state.unhealthyUntil) } func (c *Client) noteEmptyResponse(model string) { c.modelHealthMu.Lock() defer c.modelHealthMu.Unlock() state := c.modelHealth[model] state.consecutiveEmpty++ if state.consecutiveEmpty >= emptyResponseCircuitThreshold { state.unhealthyUntil = time.Now().Add(emptyResponseCircuitTTL) if c.log != nil { c.log.Warn("metadata model marked temporarily unhealthy after repeated empty responses", slog.String("provider", c.name), slog.String("model", model), slog.Time("until", state.unhealthyUntil), ) } } c.modelHealth[model] = state } func (c *Client) noteModelSuccess(model string) { c.modelHealthMu.Lock() defer c.modelHealthMu.Unlock() delete(c.modelHealth, model) } func (c *Client) notePermanentModelFailure(model string, err error) { c.modelHealthMu.Lock() defer c.modelHealthMu.Unlock() state := c.modelHealth[model] state.consecutiveEmpty = emptyResponseCircuitThreshold state.unhealthyUntil = time.Now().Add(permanentModelFailureTTL) c.modelHealth[model] = state if c.log != nil { c.log.Warn("metadata model marked unhealthy after permanent failure", slog.String("provider", c.name), slog.String("model", model), slog.String("error", err.Error()), slog.Time("until", state.unhealthyUntil), ) } }