package openai import ( "github.com/openai/openai-go/v3/responses" "ai-css/library/modelprovider/errorswrap" "bufio" "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "ai-css/library/logger" ) type EventType string const ( StreamRespondError EventType = "response.error" StreamRespondFailed EventType = "response.failed" StreamRespondOutputTextDelta EventType = "response.output_text.delta" StreamRespondComplete EventType = "response.completed" ) var NetworkError = errors.New("network unreachable") // OpenAIResponsesRequest models POST /v1/responses request body. type OpenAIResponsesRequest struct { Background *bool `json:"background,omitempty"` Conversation json.RawMessage `json:"conversation,omitempty"` // string 或 {id: "..."} 等,用 RawMessage 保持灵活 Include []string `json:"include,omitempty"` Input interface{} `json:"input,omitempty"` // 聊天场景我们会塞 []OpenAIChatMessage,其他场景可自定义 Instructions string `json:"instructions,omitempty"` MaxOutputTokens *int `json:"max_output_tokens,omitempty"` MaxToolCalls *int `json:"max_tool_calls,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` Model string `json:"model,omitempty"` ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` PreviousResponseID string `json:"previous_response_id,omitempty"` Prompt json.RawMessage `json:"prompt,omitempty"` // prompt 模板引用,结构不固定,用 RawMessage PromptCacheKey string `json:"prompt_cache_key,omitempty"` Reasoning json.RawMessage `json:"reasoning,omitempty"` // {effort: "..."} 等 Summary string `json:"summary,omitempty"` SafetyIdentifier string `json:"safety_identifier,omitempty"` ServiceTier string `json:"service_tier,omitempty"` Store *bool `json:"store,omitempty"` Stream bool `json:"stream,omitempty"` StreamOptions json.RawMessage `json:"stream_options,omitempty"` // e.g. {"include_usage": true} Temperature *float32 `json:"temperature,omitempty"` Text json.RawMessage `json:"text,omitempty"` // 结构化输出配置等 ToolChoice json.RawMessage `json:"tool_choice,omitempty"` Tools json.RawMessage `json:"tools,omitempty"` // 工具 / 函数 / MCP 定义 TopLogprobs *int `json:"top_logprobs,omitempty"` TopP *float32 `json:"top_p,omitempty"` Truncation string `json:"truncation,omitempty"` } type OpenAIResponsesResponse struct { ID string `json:"id"` Object string `json:"object"` CreatedAt int64 `json:"created_at"` Status string `json:"status"` Error OpenAIErrorMessage `json:"error,omitempty"` // 可能是 null 或对象 IncompleteDetails any `json:"incomplete_details,omitempty"` // 可能是 null 或对象 Instructions *string `json:"instructions,omitempty"` MaxOutputTokens *int `json:"max_output_tokens,omitempty"` Model string `json:"model"` Output []OutputItem `json:"output"` ParallelToolCalls bool `json:"parallel_tool_calls"` PreviousResponseID *string `json:"previous_response_id,omitempty"` Reasoning Reasoning `json:"reasoning"` Store bool `json:"store"` Temperature float64 `json:"temperature"` Text TextSpec `json:"text"` ToolChoice string `json:"tool_choice"` // "auto" | 其他 Tools []json.RawMessage `json:"tools"` // 留作将来扩展(function/tool schemas 等) TopP float64 `json:"top_p"` Truncation string `json:"truncation"` Usage Usage `json:"usage"` User *string `json:"user,omitempty"` Metadata map[string]any `json:"metadata"` } type OpenAIErrorMessage struct { Msg string `json:"message"` Type string `json:"type"` Param string `json:"model"` Code string `json:"model_not_found"` } // ResponsesStreamEvent 流事件的通用结构 type ResponsesStreamEvent struct { Type string `json:"type"` // e.g. "response.output_text.delta" Delta string `json:"delta,omitempty"` // 文本增量内容(仅在 output_text.delta 事件里有) ItemID string `json:"item_id,omitempty"` // 其他字段可以按需用 OutputIndex int `json:"output_index,omitempty"` // 这里先不用 ContentIndex int `json:"content_index,omitempty"` // 错误事件: type = "response.error" / "response.failed" Error *struct { Code string `json:"code"` Message string `json:"message"` } `json:"error,omitempty"` Response responses.Response `json:"response"` } type OutputItem struct { Type string `json:"type"` // "message" 等 ID string `json:"id"` Status string `json:"status"` // "completed" 等 Role string `json:"role"` // "assistant" 等 Content []ContentBlock `json:"content"` } type ContentBlock struct { Type string `json:"type"` // "output_text" 等 Text string `json:"text,omitempty"` // 当 type=output_text 时存在 Annotations []any `json:"annotations,omitempty"` // 留空/数组 // 未来还可能有其他字段(如 tool_calls 等),用 RawMessage 兜底更安全: // Raw json to keep forward-compatibility: // Raw json.RawMessage `json:"-"` } type Reasoning struct { Effort *string `json:"effort,omitempty"` Summary *string `json:"summary,omitempty"` } type TextSpec struct { Format TextFormat `json:"format"` } type TextFormat struct { Type string `json:"type"` // "text" } type Usage struct { InputTokens int `json:"input_tokens"` InputTokensDetails InputTokensDetails `json:"input_tokens_details"` OutputTokens int `json:"output_tokens"` OutputTokensDetails OutputTokensDetail `json:"output_tokens_details"` TotalTokens int `json:"total_tokens"` } type InputTokensDetails struct { CachedTokens int `json:"cached_tokens"` } type OutputTokensDetail struct { ReasoningTokens int `json:"reasoning_tokens"` } type OpenAIChatMessage struct { Role string `json:"role"` // "system" / "user" / "assistant" Content []interface{} `json:"content"` // 多模态就多个 part,这里只放 text } // 单条内容片段(这里只演示 text) type OpenAIContentPart struct { Type string `json:"type"` // "text" Text string `json:"text,omitempty"` // 文本内容 } // 文本输入 type TextInput struct { Type string `json:"type"` // 固定为 "input_text" Text string `json:"text"` } // 图片输入 type ImageInput struct { Type string `json:"type"` // 固定为 "input_image" ImageURL string `json:"image_url,omitempty"` // URL 或 Base64 Detail string `json:"detail,omitempty"` // high / low / auto FileID string `json:"file_id,omitempty"` // 若图片来自文件API } // 文件输入 type FileInput struct { Type string `json:"type"` // 固定为 "input_file" FileID string `json:"file_id,omitempty"` // Files API 上传返回的 ID FileData string `json:"file_data,omitempty"` // Base64 文件内容 FileURL string `json:"file_url,omitempty"` // 文件URL Filename string `json:"filename,omitempty"` // 文件名(可选) } // Model 表示单个模型对象 type Model struct { ID string `json:"id"` Object string `json:"object"` Created int64 `json:"created"` OwnedBy string `json:"owned_by"` } // ModelsResponse 表示 /v1/models 的响应结构 type ModelsResponse struct { Object string `json:"object"` // 固定为 "list" Data []Model `json:"data"` Error RespError `json:"error"` } type RespError struct { Msg string `json:"message"` Type string `json:"type"` Code string `json:"code"` } type OpenAIClient struct { apiKey string baseURL string httpClient *http.Client } func NewOpenaiClient(apikey, apiUrl string, httpC *http.Client) OpenAIClient { return OpenAIClient{apikey, apiUrl, httpC} } // callResponses 调用openAI Responses 接口 func (o *OpenAIClient) callResponses( ctx context.Context, req *OpenAIResponsesRequest, callback func(evt *ResponsesStreamEvent) error, ) (resp *OpenAIResponsesResponse, err error) { reqBody, err := json.Marshal(req) if err != nil { err = fmt.Errorf("failed to serialize request: %w", err) return } // 2. Send POST to /v1/responses httpReq, err := http.NewRequestWithContext( ctx, http.MethodPost, o.baseURL+"/v1/responses", bytes.NewBuffer(reqBody), ) if err != nil { logger.Errorf("new request failed err:%v", err) err = fmt.Errorf("failed to create HTTP request: %w", err) return } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+o.apiKey) httpReq.Header.Set("Accept", "text/event-stream") logger.Debugf("openai callResponses req:%s", string(reqBody)) respond, err := o.httpClient.Do(httpReq) if err != nil { logger.Errorf("call responses api failed err:%v", err) err = NetworkError return } defer respond.Body.Close() if respond.StatusCode != http.StatusOK { body, _ := io.ReadAll(respond.Body) var respondData *OpenAIResponsesResponse json.Unmarshal(body, &respondData) err = fmt.Errorf("OpenAI API returned error [%d]: %s", respond.StatusCode, string(body)) return } // 3. Parse SSE stream reader := bufio.NewReader(respond.Body) for { select { case <-ctx.Done(): err = ctx.Err() logger.Errorf("lisent stream failed err:%v", err) if err == io.EOF { return } err = NetworkError return default: } var line []byte line, err = reader.ReadBytes('\n') if err != nil { if err == io.EOF { return } logger.Errorf("read body failed err:%v", err) err = NetworkError return } line = bytes.TrimSpace(line) if len(line) == 0 { continue } if !bytes.HasPrefix(line, []byte("data: ")) { continue } data := bytes.TrimPrefix(line, []byte("data: ")) var event = new(ResponsesStreamEvent) if err = json.Unmarshal(data, event); err != nil { continue } if err = callback(event); err != nil { err = fmt.Errorf("callback execution failed: %w", err) return } } } func (o *OpenAIClient) getModels(ctx context.Context) (*ModelsResponse, error) { req, err := http.NewRequestWithContext( ctx, http.MethodGet, o.baseURL+"/v1/models", nil, ) if err != nil { logger.Errorf("new request failed err:%v", err) return nil, errorswrap.NewError(errorswrap.ErrorProviderApiUrlInvalid) } req.Header.Set("Authorization", "Bearer "+o.apiKey) resp, err := o.httpClient.Do(req) if err != nil { logger.Infof("call openai api failed err:%v,openAIclient:%v", err, o) return nil, errorswrap.NewError(errorswrap.ErrorProviderApiUrlInvalid) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { logger.Errorf("status code not ok code:%d", resp.StatusCode) var body []byte body, err = io.ReadAll(resp.Body) if err != nil { logger.Errorf("read response body failed: %v", err) return nil, errorswrap.NewError(errorswrap.ErrorProviderApiUrlInvalid) } logger.Errorf("status code not ok body:%s", string(body)) return nil, errorswrap.NewError(errorswrap.ErrorProviderApiKeyInvalid) } var result ModelsResponse if err = json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, err } if result.Error.Msg != "" { return nil, errorswrap.NewError(errorswrap.ErrorProviderApiKeyInvalid) } return &result, nil } func NewTextPart(isInput bool, text string) TextInput { var prefix = "output" if isInput { prefix = "input" } return TextInput{ Type: fmt.Sprintf("%s_text", prefix), Text: text, } } func NewImagePart(isInput bool, ImageURL string) ImageInput { var prefix = "output" if isInput { prefix = "input" } return ImageInput{ Type: fmt.Sprintf("%s_image", prefix), ImageURL: ImageURL, } }