{"repo": "canhta/cred", "files": {"internal/nominate/openai.go": {"e1902ede175e354d975107aedca8ad4ac3deead3": {"t": 1785746081.0396042, "n": 5924, "h": "3310d0d36b0c0d3c", "c": "package nominate\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"time\"\n)\n\n// OpenAICompatModel calls any OpenAI-compatible /chat/completions endpoint —\n// DeepSeek, OpenAI, Together, a local vLLM, anything speaking that dialect. A\n// third provider is a third Model, not a framework.\n//\n// Two things differ from Anthropic. First, the schema is not a request\n// parameter: JSON mode here (response_format {type: json_object}) forces\n// syntactically valid JSON but enforces no schema, so the schema rides in the\n// prompt and the local Valid check remains the real contract. Second,\n// truncation is spelled \"length\" here and \"max_tokens\" at Anthropic; this Model\n// normalizes to StopLength so the Extractor's single truncation check holds for\n// every provider. Returning the raw \"length\" would let a truncated response — a\n// valid JSON prefix that parses and passes — slip through as if complete.\ntype OpenAICompatModel struct {\n\tapiKey string\n\tbaseURL string\n\tmodel string\n\t// maxTokens bounds the candidate array. A truncated response is discarded\n\t// (StopLength), so the ceiling is generous rather than tight.\n\tmaxTokens int\n\thttp *http.Client\n}\n\n// OpenAIOption configures an OpenAICompatModel.\ntype OpenAIOption func(*OpenAICompatModel)\n\n// WithOpenAIModel overrides the model id.\nfunc WithOpenAIModel(id string) OpenAIOption {\n\treturn func(m *OpenAICompatModel) {\n\t\tif id != \"\" {\n\t\t\tm.model = id\n\t\t}\n\t}\n}\n\n// WithOpenAIHTTPClient overrides the HTTP client (tests inject a fake).\nfunc WithOpenAIHTTPClient(c *http.Client) OpenAIOption {\n\treturn func(m *OpenAICompatModel) { m.http = c }\n}\n\n// NewOpenAICompatModel builds a Model against baseURL (e.g.\n// \"https://api.deepseek.com\"). An empty key or base URL is an error rather than\n// a model that fails only on first use.\nfunc NewOpenAICompatModel(apiKey, baseURL, model string, opts ...OpenAIOption) (*OpenAICompatModel, error) {\n\tif apiKey == \"\" {\n\t\treturn nil, fmt.Errorf(\"nominate: OpenAI-compatible model requires an API key\")\n\t}\n\tif baseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"nominate: OpenAI-compatible model requires a base URL\")\n\t}\n\tm := &OpenAICompatModel{\n\t\tapiKey: apiKey,\n\t\tbaseURL: baseURL,\n\t\tmodel: model,\n\t\tmaxTokens: defaultMaxTokens,\n\t\thttp: &http.Client{Timeout: 60 * time.Second},\n\t}\n\tfor _, o := range opts {\n\t\to(m)\n\t}\n\tif m.model == \"\" {\n\t\treturn nil, fmt.Errorf(\"nominate: OpenAI-compatible model requires a model id\")\n\t}\n\treturn m, nil\n}\n\ntype openAIRequest struct {\n\tModel string `json:\"model\"`\n\tMaxTokens int `json:\"max_tokens\"`\n\tMessages []openAIMessage `json:\"messages\"`\n\tResponseFormat openAIRespFmt `json:\"response_format\"`\n\t// Deterministic-as-possible: nomination is an extraction, not a\n\t// brainstorm. Temperature 0 is honored by DeepSeek and OpenAI alike.\n\tTemperature float64 `json:\"temperature\"`\n}\n\ntype openAIRespFmt struct {\n\tType string `json:\"type\"`\n}\n\ntype openAIMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n}\n\ntype openAIResponse struct {\n\tChoices []struct {\n\t\tMessage struct {\n\t\t\tContent string `json:\"content\"`\n\t\t} `json:\"message\"`\n\t\tFinishReason string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage struct {\n\t\tPromptTokens int `json:\"prompt_tokens\"`\n\t\tCompletionTokens int `json:\"completion_tokens\"`\n\t} `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t\tType string `json:\"type\"`\n\t} `json:\"error\"`\n}\n\n// Generate implements Model.\nfunc (m *OpenAICompatModel) Generate(ctx context.Context, prompt string, schema []byte) ([]byte, string, Usage, error) {\n\t// JSON mode enforces valid JSON but not the schema, and the docs warn that\n\t// without an explicit JSON instruction the model can emit unbounded\n\t// whitespace. So the schema and the instruction ride in the prompt.\n\tsys := fmt.Sprintf(\n\t\t\"Respond with a single JSON object that conforms to this JSON Schema. \"+\n\t\t\t\"Output only the JSON object, no prose.\\n\\nSchema:\\n%s\", schema)\n\n\tbody, err := json.Marshal(openAIRequest{\n\t\tModel: m.model,\n\t\tMaxTokens: m.maxTokens,\n\t\tResponseFormat: openAIRespFmt{Type: \"json_object\"},\n\t\tTemperature: 0,\n\t\tMessages: []openAIMessage{\n\t\t\t{Role: \"system\", Content: sys},\n\t\t\t{Role: \"user\", Content: prompt},\n\t\t},\n\t})\n\tif err != nil {\n\t\treturn nil, \"\", Usage{}, fmt.Errorf(\"marshal request: %w\", err)\n\t}\n\n\turl := m.baseURL + \"/chat/completions\"\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))\n\tif err != nil {\n\t\treturn nil, \"\", Usage{}, fmt.Errorf(\"build request: %w\", err)\n\t}\n\treq.Header.Set(\"content-type\", \"application/json\")\n\treq.Header.Set(\"authorization\", \"Bearer \"+m.apiKey)\n\n\tresp, err := m.http.Do(req)\n\tif err != nil {\n\t\treturn nil, \"\", Usage{}, fmt.Errorf(\"call model: %w\", err)\n\t}\n\tdefer func() { _ = resp.Body.Close() }()\n\n\tvar decoded openAIResponse\n\tif err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {\n\t\treturn nil, \"\", Usage{}, fmt.Errorf(\"decode response (status %d): %w\", resp.StatusCode, err)\n\t}\n\tusage := Usage{\n\t\tInputTokens: decoded.Usage.PromptTokens,\n\t\tOutputTokens: decoded.Usage.CompletionTokens,\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tif decoded.Error != nil {\n\t\t\treturn nil, \"\", usage, fmt.Errorf(\"model %d %s: %s\",\n\t\t\t\tresp.StatusCode, decoded.Error.Type, decoded.Error.Message)\n\t\t}\n\t\treturn nil, \"\", usage, fmt.Errorf(\"model returned status %d\", resp.StatusCode)\n\t}\n\tif len(decoded.Choices) == 0 {\n\t\treturn nil, \"\", usage, fmt.Errorf(\"model returned no choices\")\n\t}\n\n\tchoice := decoded.Choices[0]\n\t// Normalize truncation to the canonical StopLength so the Extractor's\n\t// provider-agnostic check catches it. \"length\" is OpenAI's spelling;\n\t// anything else passes through unchanged for diagnostics.\n\tstop := choice.FinishReason\n\tif stop == \"length\" {\n\t\tstop = StopLength\n\t}\n\treturn []byte(choice.Message.Content), stop, usage, nil\n}\n\nvar _ Model = (*OpenAICompatModel)(nil)\n"}}}}