feat(ai): 支持 OpenAI Responses 协议

- 设置中心为 OpenAI 预设增加 Chat 与 Responses 协议选择
- 实现 Responses API 同步、流式、推理摘要及工具调用适配
- 支持会话状态回放与上游能力不兼容时的自动降级
- 完善地址、模型列表和思考强度识别并补充回归测试
This commit is contained in:
Syngnat
2026-07-13 12:55:58 +08:00
parent 933e601816
commit b9e890379e
18 changed files with 1593 additions and 21 deletions

View File

@@ -328,6 +328,7 @@ export const AISettingsContent: React.FC<AISettingsContentProps> = ({ active, da
// 尝试根据 baseUrl 和 type 推断 preset
const matchedPreset = matchProviderPreset(editableProvider);
const resolvedTransport = resolvePresetTransport({
presetKey: matchedPreset.key,
presetBackendType: matchedPreset.backendType,
presetFixedApiFormat: matchedPreset.fixedApiFormat,
valuesApiFormat: editableProvider.apiFormat,
@@ -396,6 +397,7 @@ export const AISettingsContent: React.FC<AISettingsContentProps> = ({ active, da
valuesBaseUrl: values.baseUrl,
});
const resolvedTransport = resolvePresetTransport({
presetKey: values.presetKey,
presetBackendType: preset.backendType,
presetFixedApiFormat: preset.fixedApiFormat,
valuesApiFormat: values.apiFormat,
@@ -664,6 +666,7 @@ export const AISettingsContent: React.FC<AISettingsContentProps> = ({ active, da
customModels: values.models,
});
const resolvedTransport = resolvePresetTransport({
presetKey: values.presetKey || 'openai',
presetBackendType: preset.backendType,
presetFixedApiFormat: preset.fixedApiFormat,
valuesApiFormat: values.apiFormat,
@@ -698,6 +701,7 @@ export const AISettingsContent: React.FC<AISettingsContentProps> = ({ active, da
const handlePresetChange = (presetKey: string) => {
const preset = findPreset(presetKey);
const resolvedTransport = resolvePresetTransport({
presetKey,
presetBackendType: preset.backendType,
presetFixedApiFormat: preset.fixedApiFormat,
valuesApiFormat: form.getFieldValue('apiFormat'),

View File

@@ -166,6 +166,49 @@ describe('AISettingsProvidersSection', () => {
expect(markup).toContain('Auto-completion model');
expect(markup).toContain('API Endpoint (URL)');
expect(markup).toContain('Test connection');
expect(markup).toContain('OpenAI Responses');
});
it('renders the Responses protocol selector for the built-in OpenAI preset', () => {
const Wrap = () => {
const [form] = Form.useForm();
return (
<AISettingsProvidersSection
providers={[provider]}
activeProviderId="provider-1"
editingProvider={{ ...provider, apiFormat: 'openai-responses' }}
isEditing
form={form}
providerPresets={providerPresets}
watchedPresetKey="openai"
watchedApiFormat="openai-responses"
loading={false}
testStatus="idle"
primaryPasswordVisible={false}
darkMode={false}
overlayTheme={overlayTheme}
cardBg="#fff"
cardBorder="rgba(0,0,0,0.08)"
inputBg="#fff"
onPrimaryPasswordVisibleChange={() => {}}
resolveProviderPreset={() => ({ label: 'OpenAI', icon: <span>O</span> })}
resolvePresetByKey={(key) => providerPresets.find((item) => item.key === key) || providerPresets[0]}
onAddProvider={() => {}}
onEditProvider={() => {}}
onDeleteProvider={() => {}}
onSetActiveProvider={() => {}}
onCancelEdit={() => {}}
onPresetChange={() => {}}
onTestProvider={() => {}}
onSaveProvider={() => {}}
/>
);
};
const markup = renderToStaticMarkup(<Wrap />);
expect(markup).toContain('API format');
expect(markup).toContain('OpenAI Chat');
expect(markup).toContain('OpenAI Responses');
});
it('uses catalog keys for provider list and form chrome', () => {

View File

@@ -113,8 +113,22 @@ const AISettingsProvidersSection: React.FC<AISettingsProvidersSectionProps> = ({
const copy = (key: string) => (i18n?.t ?? ((catalogKey) => catalogTranslate('en-US', catalogKey)))(key);
const presetKeyFromForm = watchedPresetKey || (editingProvider as (AIProviderConfig & { presetKey?: string }) | null)?.presetKey || 'openai';
const supportsAdvancedEndpoint = presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama' || presetKeyFromForm === 'codebuddy' || presetKeyFromForm === 'cursor';
const showsApiFormat = presetKeyFromForm === 'custom' || presetKeyFromForm === 'openai';
const codeBuddyUsesOptionalSecret = presetKeyFromForm === 'codebuddy';
const cursorUsesOptionalModel = presetKeyFromForm === 'cursor';
const apiFormatOptions = presetKeyFromForm === 'openai'
? [
{ value: 'openai', label: 'OpenAI Chat' },
{ value: 'openai-responses', label: 'OpenAI Responses' },
]
: [
{ value: 'openai', label: 'OpenAI Chat' },
{ value: 'openai-responses', label: 'OpenAI Responses' },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'cursor-agent', label: 'Cursor Agent' },
{ value: 'claude-cli', label: 'Claude CLI' },
];
const sectionLabelColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)';
const currentFieldGroupStyle = fieldGroupStyle(cardBorder, cardBg);
const currentFieldLabelStyle = fieldLabelStyle(sectionLabelColor);
@@ -305,7 +319,7 @@ const AISettingsProvidersSection: React.FC<AISettingsProvidersSectionProps> = ({
<Form.Item name="type" hidden><Input /></Form.Item>
</div>
{supportsAdvancedEndpoint && (
{(supportsAdvancedEndpoint || showsApiFormat) && (
<div style={{ ...currentFieldGroupStyle, marginTop: 16 }}>
<div style={currentFieldLabelStyle}>
<RobotOutlined style={{ fontSize: 14 }} /> {copy('ai_settings.form.section.basic')}
@@ -321,16 +335,18 @@ const AISettingsProvidersSection: React.FC<AISettingsProvidersSectionProps> = ({
</Form.Item>
)}
{presetKeyFromForm === 'custom' && (
{showsApiFormat && (
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>{copy('ai_settings.form.api_format')}</span>} name="apiFormat" style={{ marginBottom: 16 }}>
<div style={{
display: 'inline-flex',
flexWrap: 'wrap',
maxWidth: '100%',
padding: 4,
background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.04)',
borderRadius: 8,
gap: 4,
}}>
{[{ value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'gemini', label: 'Gemini' }, { value: 'cursor-agent', label: 'Cursor Agent' }, { value: 'claude-cli', label: 'Claude CLI' }].map((format) => (
{apiFormatOptions.map((format) => (
<div
key={format.value}
onClick={() => form.setFieldsValue({ apiFormat: format.value })}
@@ -339,6 +355,7 @@ const AISettingsProvidersSection: React.FC<AISettingsProvidersSectionProps> = ({
borderRadius: 6,
fontSize: 13,
fontWeight: watchedApiFormat === format.value ? 600 : 500,
whiteSpace: 'nowrap',
cursor: 'pointer',
background: watchedApiFormat === format.value ? (darkMode ? '#374151' : '#ffffff') : 'transparent',
color: watchedApiFormat === format.value ? overlayTheme.titleText : overlayTheme.mutedText,
@@ -353,18 +370,20 @@ const AISettingsProvidersSection: React.FC<AISettingsProvidersSectionProps> = ({
</Form.Item>
)}
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>{copy('ai_settings.form.model_list')}</span>} name="models" style={{ marginBottom: 0 }}>
<Select
mode="tags"
size="middle"
placeholder={codeBuddyUsesOptionalSecret
? copy('ai_settings.form.model_list_placeholder.codebuddy')
: cursorUsesOptionalModel
? copy('ai_settings.form.model_list_placeholder.cursor')
: copy('ai_settings.form.model_list_placeholder')}
style={{ width: '100%' }}
/>
</Form.Item>
{supportsAdvancedEndpoint && (
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>{copy('ai_settings.form.model_list')}</span>} name="models" style={{ marginBottom: 0 }}>
<Select
mode="tags"
size="middle"
placeholder={codeBuddyUsesOptionalSecret
? copy('ai_settings.form.model_list_placeholder.codebuddy')
: cursorUsesOptionalModel
? copy('ai_settings.form.model_list_placeholder.cursor')
: copy('ai_settings.form.model_list_placeholder')}
style={{ width: '100%' }}
/>
</Form.Item>
)}
</div>
)}
<Form.Item name="model" hidden><Input /></Form.Item>

View File

@@ -628,7 +628,7 @@ export interface AIProviderConfig {
model: string;
inlineCompletionModel?: string;
models?: string[];
apiFormat?: string; // custom 专用: openai | anthropic | gemini | cursor-agent | claude-cli | codebuddy-cli
apiFormat?: string; // openai 可选 openai-responsescustom 支持 openai/anthropic/gemini/CLI 等格式
headers?: Record<string, string>;
maxTokens: number;
temperature: number;

View File

@@ -161,6 +161,37 @@ describe('ai provider preset helpers', () => {
apiFormat: 'gemini',
});
});
it('preserves the Responses protocol for the built-in OpenAI preset', () => {
expect(resolvePresetTransport({
presetBackendType: 'openai',
valuesApiFormat: 'openai-responses',
})).toEqual({
type: 'openai',
apiFormat: 'openai-responses',
});
});
it('keeps the legacy OpenAI protocol implicit for existing configurations', () => {
expect(resolvePresetTransport({
presetBackendType: 'openai',
valuesApiFormat: 'openai',
})).toEqual({
type: 'openai',
apiFormat: undefined,
});
});
it('does not carry the Responses protocol into another OpenAI-compatible preset', () => {
expect(resolvePresetTransport({
presetKey: 'deepseek',
presetBackendType: 'openai',
valuesApiFormat: 'openai-responses',
})).toEqual({
type: 'openai',
apiFormat: undefined,
});
});
});
describe('resolveProviderPresetKey', () => {

View File

@@ -39,6 +39,7 @@ export interface ResolvePresetBaseURLInput {
}
export interface ResolvePresetTransportInput {
presetKey?: string;
presetBackendType: AIProviderType;
presetFixedApiFormat?: string;
valuesApiFormat?: string;
@@ -208,6 +209,7 @@ export const resolvePresetBaseURL = ({
};
export const resolvePresetTransport = ({
presetKey,
presetBackendType,
presetFixedApiFormat,
valuesApiFormat,
@@ -226,6 +228,17 @@ export const resolvePresetTransport = ({
};
}
if (
presetBackendType === 'openai'
&& valuesApiFormat === 'openai-responses'
&& (presetKey === undefined || presetKey === 'openai')
) {
return {
type: presetBackendType,
apiFormat: 'openai-responses',
};
}
return {
type: presetBackendType,
apiFormat: undefined,

View File

@@ -15,6 +15,15 @@ describe('aiThinkingIntensity', () => {
})).toBe('openai');
});
it('detects OpenAI profile for the Responses API format', () => {
expect(resolveThinkingIntensityProfile({
type: 'custom',
apiFormat: 'openai-responses',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-5.4',
})).toBe('openai');
});
it('detects DeepSeek even when api format is anthropic', () => {
expect(resolveThinkingIntensityProfile({
type: 'custom',

View File

@@ -77,7 +77,7 @@ export const resolveThinkingIntensityProfile = (input: {
if (type === 'anthropic' || format === 'anthropic') {
return 'anthropic';
}
if (type === 'openai' || format === 'openai' || format === '') {
if (type === 'openai' || format === 'openai' || format === 'openai-responses' || format === '') {
return 'openai';
}
return 'generic';

View File

@@ -10,7 +10,7 @@ import (
)
// CustomProvider 自定义 Provider根据 apiFormat 选择底层协议
// 支持 openai / anthropic / gemini / cursor-agent 等 API 格式
// 支持 openai / openai-responses / anthropic / gemini / cursor-agent 等 API 格式
type CustomProvider struct {
inner Provider
name string
@@ -30,6 +30,8 @@ func NewCustomProvider(config ai.ProviderConfig) (Provider, error) {
var innerProvider Provider
var err error
switch apiFormat {
case "openai-responses":
innerProvider, err = NewOpenAIResponsesProvider(config)
case "anthropic":
innerProvider, err = NewAnthropicProvider(config)
case "gemini":

View File

@@ -67,6 +67,8 @@ func normalizeOpenAICompatibleBaseURLString(raw string) string {
switch {
case strings.HasSuffix(lower, "/chat/completions"):
normalized = normalized[:len(normalized)-len("/chat/completions")]
case strings.HasSuffix(lower, "/responses"):
normalized = normalized[:len(normalized)-len("/responses")]
case strings.HasSuffix(lower, "/models"):
normalized = normalized[:len(normalized)-len("/models")]
}
@@ -83,6 +85,8 @@ func normalizeOpenAICompatiblePath(path string) string {
switch {
case strings.HasSuffix(lower, "/chat/completions"):
normalized = normalized[:len(normalized)-len("/chat/completions")]
case strings.HasSuffix(lower, "/responses"):
normalized = normalized[:len(normalized)-len("/responses")]
case strings.HasSuffix(lower, "/models"):
normalized = normalized[:len(normalized)-len("/models")]
}

View File

@@ -0,0 +1,707 @@
package provider
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"GoNavi-Wails/internal/ai"
)
// OpenAIResponsesProvider 实现 OpenAI Responses API并将 Items/SSE 事件
// 适配为 GoNavi 内部统一的消息、工具调用和流式片段。
type OpenAIResponsesProvider struct {
config ai.ProviderConfig
baseURL string
client *http.Client
}
func NewOpenAIResponsesProvider(config ai.ProviderConfig) (Provider, error) {
baseURL := NormalizeOpenAICompatibleBaseURL(config.BaseURL)
model := strings.TrimSpace(config.Model)
if model == "" {
return nil, fmt.Errorf("model ID is required; select or enter a model in Settings")
}
maxTokens := config.MaxTokens
if maxTokens <= 0 {
maxTokens = defaultOpenAIMaxTokens
}
temperature := config.Temperature
if temperature <= 0 {
temperature = defaultOpenAITemperature
}
normalized := config
normalized.BaseURL = baseURL
normalized.Model = model
normalized.MaxTokens = maxTokens
normalized.Temperature = temperature
profile := ResolveThinkingProfile(config.Type, config.APIFormat, baseURL, model)
normalized.ThinkingIntensity = string(clampThinkingIntensityToProfile(config.ThinkingIntensity, profile))
return &OpenAIResponsesProvider{
config: normalized,
baseURL: baseURL,
client: &http.Client{
Timeout: openAIHTTPTimeout,
},
}, nil
}
func (p *OpenAIResponsesProvider) Name() string {
if strings.TrimSpace(p.config.Name) != "" {
return p.config.Name
}
return "OpenAI Responses"
}
func (p *OpenAIResponsesProvider) Validate() error {
if strings.TrimSpace(p.config.APIKey) == "" {
return fmt.Errorf("API key is required")
}
return nil
}
type openAIResponsesRequest struct {
Model string `json:"model"`
Input []json.RawMessage `json:"input"`
Temperature float64 `json:"temperature,omitempty"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
Stream bool `json:"stream"`
Store bool `json:"store"`
Include []string `json:"include,omitempty"`
Tools []openAIResponsesTool `json:"tools,omitempty"`
Reasoning *openAIResponsesReasoning `json:"reasoning,omitempty"`
}
type openAIResponsesSessionState struct {
Input []json.RawMessage `json:"input"`
}
type openAIResponsesReasoning struct {
Effort string `json:"effort,omitempty"`
Summary string `json:"summary,omitempty"`
}
type openAIResponsesInputItem struct {
Type string `json:"type,omitempty"`
Role string `json:"role,omitempty"`
Content any `json:"content,omitempty"`
CallID string `json:"call_id,omitempty"`
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
Output string `json:"output,omitempty"`
}
type openAIResponsesContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL string `json:"image_url,omitempty"`
}
type openAIResponsesTool struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters any `json:"parameters,omitempty"`
Strict bool `json:"strict"`
}
type openAIResponsesError struct {
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
type openAIResponsesOutputItem struct {
ID string `json:"id,omitempty"`
Type string `json:"type"`
Role string `json:"role,omitempty"`
Status string `json:"status,omitempty"`
CallID string `json:"call_id,omitempty"`
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
Content []struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Refusal string `json:"refusal,omitempty"`
} `json:"content,omitempty"`
Summary []struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
} `json:"summary,omitempty"`
}
type openAIResponsesResponse struct {
ID string `json:"id"`
Status string `json:"status,omitempty"`
Output []json.RawMessage `json:"output"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
Error *openAIResponsesError `json:"error,omitempty"`
IncompleteDetails *struct {
Reason string `json:"reason,omitempty"`
} `json:"incomplete_details,omitempty"`
}
type openAIResponsesStreamEvent struct {
Type string `json:"type"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Delta string `json:"delta,omitempty"`
Arguments string `json:"arguments,omitempty"`
Name string `json:"name,omitempty"`
OutputIndex int `json:"output_index,omitempty"`
Item openAIResponsesOutputItem `json:"item,omitempty"`
Response openAIResponsesResponse `json:"response,omitempty"`
Error *openAIResponsesError `json:"error,omitempty"`
}
func buildOpenAIResponsesTools(tools []ai.Tool) []openAIResponsesTool {
if len(tools) == 0 {
return nil
}
result := make([]openAIResponsesTool, 0, len(tools))
for _, tool := range tools {
result = append(result, openAIResponsesTool{
Type: "function",
Name: tool.Function.Name,
Description: tool.Function.Description,
Parameters: tool.Function.Parameters,
// Chat Completions 中现有工具默认是非严格模式,迁移时显式保持该语义。
Strict: false,
})
}
return result
}
func buildOpenAIResponsesInput(messages []ai.Message, baseURL string) []openAIResponsesInputItem {
items := make([]openAIResponsesInputItem, 0, len(messages))
for _, message := range messages {
if message.Role == "tool" {
items = append(items, openAIResponsesInputItem{
Type: "function_call_output",
CallID: message.ToolCallID,
Output: message.Content,
})
continue
}
if message.Content != "" || len(message.Images) > 0 || len(message.ToolCalls) == 0 {
content := any(message.Content)
if len(message.Images) > 0 {
text := message.Content
if text == "" {
text = providerImageFallbackPrompt("")
}
parts := []openAIResponsesContentPart{{Type: "input_text", Text: text}}
for _, image := range message.Images {
imageURL := image
if strings.Contains(strings.ToLower(baseURL), "bigmodel") {
if _, raw, err := ParseDataURI(image); err == nil {
imageURL = raw
}
}
parts = append(parts, openAIResponsesContentPart{Type: "input_image", ImageURL: imageURL})
}
content = parts
}
items = append(items, openAIResponsesInputItem{
Type: "message",
Role: message.Role,
Content: content,
})
}
for _, toolCall := range message.ToolCalls {
items = append(items, openAIResponsesInputItem{
Type: "function_call",
CallID: toolCall.ID,
Name: toolCall.Function.Name,
Arguments: toolCall.Function.Arguments,
})
}
}
return items
}
func marshalOpenAIResponsesInput(items []openAIResponsesInputItem) []json.RawMessage {
if len(items) == 0 {
return nil
}
result := make([]json.RawMessage, 0, len(items))
for _, item := range items {
encoded, err := json.Marshal(item)
if err == nil {
result = append(result, json.RawMessage(encoded))
}
}
return result
}
func cloneOpenAIResponsesRawItems(items []json.RawMessage) []json.RawMessage {
if len(items) == 0 {
return nil
}
result := make([]json.RawMessage, len(items))
for index, item := range items {
result[index] = append(json.RawMessage(nil), item...)
}
return result
}
func decodeOpenAIResponsesSessionState(state json.RawMessage) (openAIResponsesSessionState, bool) {
if len(state) == 0 {
return openAIResponsesSessionState{}, false
}
var decoded openAIResponsesSessionState
if err := json.Unmarshal(state, &decoded); err != nil || len(decoded.Input) == 0 {
return openAIResponsesSessionState{}, false
}
decoded.Input = cloneOpenAIResponsesRawItems(decoded.Input)
return decoded, true
}
func encodeOpenAIResponsesSessionState(input []json.RawMessage, output []json.RawMessage) (json.RawMessage, error) {
combined := make([]json.RawMessage, 0, len(input)+len(output))
combined = append(combined, cloneOpenAIResponsesRawItems(input)...)
combined = append(combined, cloneOpenAIResponsesRawItems(output)...)
encoded, err := json.Marshal(openAIResponsesSessionState{Input: combined})
if err != nil {
return nil, fmt.Errorf("serialize OpenAI Responses session state failed: %w", err)
}
return json.RawMessage(encoded), nil
}
func (p *OpenAIResponsesProvider) buildRequest(req ai.ChatRequest, stream bool) openAIResponsesRequest {
requestMessages := prepareOpenAIRequestMessagesForRequest(
req.Messages,
p.config.Model,
p.baseURL,
req.ImageFallbackPrompt,
req.ImageOmittedNotice,
)
temperature := req.Temperature
if temperature <= 0 {
temperature = p.config.Temperature
}
maxOutputTokens := req.MaxTokens
if maxOutputTokens <= 0 {
maxOutputTokens = p.config.MaxTokens
}
body := openAIResponsesRequest{
Model: p.config.Model,
Input: marshalOpenAIResponsesInput(buildOpenAIResponsesInput(requestMessages, p.baseURL)),
Temperature: temperature,
MaxOutputTokens: maxOutputTokens,
Stream: stream,
Store: false,
Include: []string{"reasoning.encrypted_content"},
Tools: buildOpenAIResponsesTools(req.Tools),
}
if intensity := NormalizeThinkingIntensity(p.config.ThinkingIntensity); intensity != "" {
if effort := openAIReasoningEffort(intensity); effort != "" {
body.Reasoning = &openAIResponsesReasoning{Effort: effort, Summary: "auto"}
}
}
return body
}
func parseOpenAIResponsesOutput(result openAIResponsesResponse) *ai.ChatResponse {
var content strings.Builder
var reasoning strings.Builder
toolCalls := make([]ai.ToolCall, 0)
for _, rawItem := range result.Output {
var item openAIResponsesOutputItem
if err := json.Unmarshal(rawItem, &item); err != nil {
continue
}
switch item.Type {
case "message":
for _, part := range item.Content {
if part.Type == "output_text" && part.Text != "" {
content.WriteString(part.Text)
}
if part.Type == "refusal" && part.Refusal != "" {
content.WriteString(part.Refusal)
}
}
case "reasoning":
for _, part := range item.Summary {
if part.Text != "" {
reasoning.WriteString(part.Text)
}
}
case "function_call":
toolCalls = append(toolCalls, ai.ToolCall{
ID: item.CallID,
Type: "function",
Function: ai.ToolCallFunction{
Name: item.Name,
Arguments: item.Arguments,
},
})
}
}
return &ai.ChatResponse{
Content: content.String(),
ReasoningContent: reasoning.String(),
ToolCalls: toolCalls,
TokensUsed: ai.TokenUsage{
PromptTokens: result.Usage.InputTokens,
CompletionTokens: result.Usage.OutputTokens,
TotalTokens: result.Usage.TotalTokens,
},
}
}
func openAIResponsesIncompleteError(result openAIResponsesResponse) error {
if result.Status != "incomplete" && result.IncompleteDetails == nil {
return nil
}
reason := ""
if result.IncompleteDetails != nil {
reason = strings.TrimSpace(result.IncompleteDetails.Reason)
}
if reason == "" {
return fmt.Errorf("OpenAI Responses response incomplete")
}
return fmt.Errorf("OpenAI Responses response incomplete: %s", reason)
}
func (p *OpenAIResponsesProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
response, _, err := p.ChatWithState(ctx, nil, req)
return response, err
}
func (p *OpenAIResponsesProvider) ChatWithState(
ctx context.Context,
state json.RawMessage,
req ai.ChatRequest,
) (*ai.ChatResponse, json.RawMessage, error) {
if err := p.Validate(); err != nil {
return nil, state, err
}
body := p.buildRequest(req, false)
if len(state) > 0 {
previous, ok := decodeOpenAIResponsesSessionState(state)
if !ok {
return nil, state, fmt.Errorf("parse OpenAI Responses session state failed")
}
body.Input = append(previous.Input, body.Input...)
}
respBody, err := p.doRequest(ctx, body)
if err != nil {
respBody, body, err = p.retryClientRejectedRequest(ctx, req, body, err)
if err != nil {
return nil, state, err
}
}
defer respBody.Close()
var result openAIResponsesResponse
if err := json.NewDecoder(respBody).Decode(&result); err != nil {
return nil, state, fmt.Errorf("parse OpenAI Responses response failed: %w", err)
}
if result.Error != nil && result.Error.Message != "" {
return nil, state, fmt.Errorf("OpenAI Responses API error: %s", result.Error.Message)
}
if err := openAIResponsesIncompleteError(result); err != nil {
return nil, state, err
}
response := parseOpenAIResponsesOutput(result)
if response.Content == "" && response.ReasoningContent == "" && len(response.ToolCalls) == 0 {
return nil, state, fmt.Errorf("OpenAI Responses returned empty response")
}
nextState, err := encodeOpenAIResponsesSessionState(body.Input, result.Output)
if err != nil {
return nil, state, err
}
return response, nextState, nil
}
func (p *OpenAIResponsesProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
_, err := p.ChatStreamWithState(ctx, nil, req, callback)
return err
}
func (p *OpenAIResponsesProvider) ChatStreamWithState(
ctx context.Context,
state json.RawMessage,
req ai.ChatRequest,
callback func(ai.StreamChunk),
) (json.RawMessage, error) {
if err := p.Validate(); err != nil {
return state, err
}
body := p.buildRequest(req, true)
if len(state) > 0 {
previous, ok := decodeOpenAIResponsesSessionState(state)
if !ok {
return state, fmt.Errorf("parse OpenAI Responses session state failed")
}
body.Input = append(previous.Input, body.Input...)
}
respBody, err := p.doRequest(ctx, body)
if err != nil {
respBody, body, err = p.retryClientRejectedRequest(ctx, req, body, err)
if err != nil {
return state, err
}
}
defer respBody.Close()
receivedText := false
receivedReasoning := false
receivedToolCall := false
toolCalls := make([]ai.ToolCall, 0)
toolCallIndexes := make(map[int]int)
upsertToolCall := func(outputIndex int, item openAIResponsesOutputItem, argumentsDelta string) {
toolIndex, ok := toolCallIndexes[outputIndex]
if !ok {
toolIndex = len(toolCalls)
toolCallIndexes[outputIndex] = toolIndex
toolCalls = append(toolCalls, ai.ToolCall{Type: "function"})
}
toolCall := &toolCalls[toolIndex]
if item.CallID != "" {
toolCall.ID = item.CallID
}
if item.Name != "" {
toolCall.Function.Name = item.Name
}
if item.Arguments != "" {
toolCall.Function.Arguments = item.Arguments
} else if argumentsDelta != "" {
toolCall.Function.Arguments += argumentsDelta
}
receivedToolCall = true
callback(ai.StreamChunk{ToolCalls: append([]ai.ToolCall(nil), toolCalls...)})
}
scanner := bufio.NewScanner(respBody)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "" {
continue
}
if data == "[DONE]" {
return state, fmt.Errorf("OpenAI Responses stream ended before response.completed")
}
var event openAIResponsesStreamEvent
if err := json.Unmarshal([]byte(data), &event); err != nil {
continue
}
switch event.Type {
case "response.output_text.delta", "response.refusal.delta":
if event.Delta != "" {
receivedText = true
callback(ai.StreamChunk{Content: event.Delta})
}
case "response.reasoning_summary_text.delta":
if event.Delta != "" {
receivedReasoning = true
callback(ai.StreamChunk{Thinking: event.Delta, ReasoningContent: event.Delta})
}
case "response.output_item.added", "response.output_item.done":
if event.Item.Type == "function_call" {
upsertToolCall(event.OutputIndex, event.Item, "")
}
case "response.function_call_arguments.delta":
upsertToolCall(event.OutputIndex, openAIResponsesOutputItem{}, event.Delta)
case "response.function_call_arguments.done":
item := event.Item
if item.Arguments == "" {
item.Arguments = event.Arguments
}
if item.Name == "" {
item.Name = event.Name
}
upsertToolCall(event.OutputIndex, item, "")
case "response.completed":
completed := parseOpenAIResponsesOutput(event.Response)
if !receivedText && completed.Content != "" {
receivedText = true
callback(ai.StreamChunk{Content: completed.Content})
}
if !receivedReasoning && completed.ReasoningContent != "" {
receivedReasoning = true
callback(ai.StreamChunk{Thinking: completed.ReasoningContent, ReasoningContent: completed.ReasoningContent})
}
if len(completed.ToolCalls) > 0 {
receivedToolCall = true
toolCalls = completed.ToolCalls
callback(ai.StreamChunk{ToolCalls: append([]ai.ToolCall(nil), toolCalls...)})
}
if !receivedText && !receivedReasoning && !receivedToolCall {
return state, fmt.Errorf("OpenAI Responses returned empty response")
}
if len(event.Response.Output) == 0 {
callback(ai.StreamChunk{Done: true})
return nil, nil
}
nextState, err := encodeOpenAIResponsesSessionState(body.Input, event.Response.Output)
if err != nil {
return state, err
}
callback(ai.StreamChunk{Done: true})
return nextState, nil
case "response.failed":
message := "OpenAI Responses request failed"
if event.Response.Error != nil && event.Response.Error.Message != "" {
message = event.Response.Error.Message
}
return state, fmt.Errorf("%s", message)
case "response.incomplete":
if incompleteErr := openAIResponsesIncompleteError(event.Response); incompleteErr != nil {
return state, incompleteErr
}
return state, fmt.Errorf("OpenAI Responses response incomplete")
case "error":
message := "OpenAI Responses streaming error"
if event.Error != nil && event.Error.Message != "" {
message = event.Error.Message
} else if event.Message != "" {
message = event.Message
}
return state, fmt.Errorf("%s", message)
}
}
if err := scanner.Err(); err != nil {
return state, fmt.Errorf("read OpenAI Responses streaming response failed: %w", err)
}
return state, fmt.Errorf("OpenAI Responses stream ended before response.completed")
}
func (p *OpenAIResponsesProvider) retryClientRejectedRequest(
ctx context.Context,
req ai.ChatRequest,
body openAIResponsesRequest,
err error,
) (io.ReadCloser, openAIResponsesRequest, error) {
if !isHTTP400Error(err) {
return nil, body, err
}
if len(body.Include) > 0 {
originalInclude := append([]string(nil), body.Include...)
body.Include = nil
respBody, retryErr := p.doRequest(ctx, body)
if retryErr == nil {
fmt.Println("[OpenAI Responses] 上游不支持 include自动降级为不请求加密推理内容")
return respBody, body, nil
}
if !isHTTP400Error(retryErr) {
return nil, body, retryErr
}
// include 不是失败原因时恢复它,后续 tools/images 降级仍保留加密推理回放能力。
body.Include = originalInclude
err = retryErr
}
if len(body.Tools) > 0 {
fmt.Println("[OpenAI Responses] 模型不支持 Function Calling自动降级为纯文本模式")
body.Tools = nil
respBody, retryErr := p.doRequest(ctx, body)
if retryErr == nil {
return respBody, body, nil
}
if !isHTTP400Error(retryErr) {
return nil, body, retryErr
}
err = retryErr
}
if requestMessagesContainImages(req.Messages) {
fmt.Println("[OpenAI Responses] 模型不支持图片输入,自动移除图片后重试")
stripped := stripImagesFromRequestMessagesWithNotice(req.Messages, req.ImageOmittedNotice)
requestInputCount := len(p.buildRequest(req, body.Stream).Input)
prefixCount := len(body.Input) - requestInputCount
if prefixCount < 0 {
prefixCount = 0
}
strippedInput := marshalOpenAIResponsesInput(buildOpenAIResponsesInput(stripped, p.baseURL))
body.Input = append(cloneOpenAIResponsesRawItems(body.Input[:prefixCount]), strippedInput...)
body.Tools = nil
respBody, retryErr := p.doRequest(ctx, body)
if retryErr == nil {
return respBody, body, nil
}
if !isHTTP400Error(retryErr) {
return nil, body, retryErr
}
err = retryErr
}
if len(body.Include) > 0 {
body.Include = nil
respBody, retryErr := p.doRequest(ctx, body)
if retryErr == nil {
fmt.Println("[OpenAI Responses] 上游不支持 include自动降级为不请求加密推理内容")
return respBody, body, nil
}
return nil, body, retryErr
}
return nil, body, err
}
func (p *OpenAIResponsesProvider) doRequest(ctx context.Context, body openAIResponsesRequest) (io.ReadCloser, error) {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("serialize request failed: %w", err)
}
endpoint := ResolveOpenAICompatibleEndpoint(p.baseURL, "responses")
requestLog := logAIUpstreamRequestStart(p.Name(), http.MethodPost, endpoint, body)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(jsonBody))
if err != nil {
logAIUpstreamRequestFinish(requestLog, 0, err)
return nil, fmt.Errorf("create HTTP request failed: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+p.config.APIKey)
if body.Stream {
httpReq.Header.Set("Accept", "text/event-stream")
httpReq.Header.Set("Cache-Control", "no-cache")
httpReq.Header.Set("Connection", "keep-alive")
}
for key, value := range p.config.Headers {
httpReq.Header.Set(key, value)
}
resp, err := p.client.Do(httpReq)
if err != nil {
logAIUpstreamRequestFinish(requestLog, 0, err)
return nil, fmt.Errorf("request to %s failed: %w", endpoint, err)
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
statusErr := fmt.Errorf("OpenAI Responses API returned error (HTTP %d): %s", resp.StatusCode, string(bodyBytes))
logAIUpstreamRequestFinish(requestLog, resp.StatusCode, statusErr)
return nil, statusErr
}
logAIUpstreamRequestFinish(requestLog, resp.StatusCode, nil)
return resp.Body, nil
}

View File

@@ -0,0 +1,716 @@
package provider
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"GoNavi-Wails/internal/ai"
)
func TestOpenAIResponsesProviderChatUsesResponsesRequestAndParsesOutputItems(t *testing.T) {
var received map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/responses" {
t.Fatalf("expected /v1/responses, got %q", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer sk-test" {
t.Fatalf("expected bearer auth, got %q", got)
}
defer r.Body.Close()
if err := json.NewDecoder(r.Body).Decode(&received); err != nil {
t.Fatalf("decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id":"resp_1",
"status":"completed",
"output":[
{"type":"reasoning","summary":[{"type":"summary_text","text":"inspect schema first"}]},
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Checking now."}]},
{"type":"function_call","call_id":"call_schema","name":"inspect_table_schema","arguments":"{\"table\":\"orders\"}"}
],
"usage":{"input_tokens":12,"output_tokens":7,"total_tokens":19}
}`))
}))
defer server.Close()
providerInstance, err := NewOpenAIResponsesProvider(ai.ProviderConfig{
Type: "custom",
APIFormat: "openai-responses",
Name: "Responses proxy",
APIKey: "sk-test",
BaseURL: server.URL + "/v1",
Model: "gpt-5.4",
MaxTokens: 4096,
Temperature: 0.2,
ThinkingIntensity: "high",
})
if err != nil {
t.Fatalf("create provider: %v", err)
}
response, err := providerInstance.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{
{Role: "system", Content: "You are a database assistant."},
{Role: "user", Content: "Inspect orders", Images: []string{"data:image/png;base64,abc"}},
{Role: "assistant", ToolCalls: []ai.ToolCall{{
ID: "call_previous",
Type: "function",
Function: ai.ToolCallFunction{
Name: "get_columns",
Arguments: `{"table":"orders"}`,
},
}},
},
{Role: "tool", ToolCallID: "call_previous", Content: `{"columns":["id"]}`},
},
Tools: []ai.Tool{{
Type: "function",
Function: ai.ToolFunction{
Name: "inspect_table_schema",
Description: "Inspect a table schema",
Parameters: map[string]any{
"type": "object",
},
},
}},
MaxTokens: 256,
Temperature: 0.1,
})
if err != nil {
t.Fatalf("chat: %v", err)
}
if received["model"] != "gpt-5.4" || received["store"] != false {
t.Fatalf("unexpected request envelope: %#v", received)
}
if stream, present := received["stream"]; present && stream != false {
t.Fatalf("expected non-stream request to omit stream or set it to false, got %#v", stream)
}
if received["max_output_tokens"] != float64(256) || received["temperature"] != 0.1 {
t.Fatalf("unexpected generation options: %#v", received)
}
reasoning, _ := received["reasoning"].(map[string]any)
if reasoning["effort"] != "high" || reasoning["summary"] != "auto" {
t.Fatalf("unexpected reasoning config: %#v", reasoning)
}
tools, _ := received["tools"].([]any)
if len(tools) != 1 {
t.Fatalf("expected one tool, got %#v", received["tools"])
}
tool, _ := tools[0].(map[string]any)
if tool["type"] != "function" || tool["name"] != "inspect_table_schema" || tool["function"] != nil {
t.Fatalf("expected internally-tagged Responses tool, got %#v", tool)
}
inputJSON, _ := json.Marshal(received["input"])
inputText := string(inputJSON)
for _, expected := range []string{`"type":"input_image"`, `"type":"function_call"`, `"call_id":"call_previous"`, `"type":"function_call_output"`} {
if !strings.Contains(inputText, expected) {
t.Fatalf("expected input to contain %s, got %s", expected, inputText)
}
}
if response.Content != "Checking now." || response.ReasoningContent != "inspect schema first" {
t.Fatalf("unexpected response content: %#v", response)
}
if response.TokensUsed != (ai.TokenUsage{PromptTokens: 12, CompletionTokens: 7, TotalTokens: 19}) {
t.Fatalf("unexpected usage: %#v", response.TokensUsed)
}
if len(response.ToolCalls) != 1 || response.ToolCalls[0].ID != "call_schema" || response.ToolCalls[0].Function.Name != "inspect_table_schema" {
t.Fatalf("unexpected tool calls: %#v", response.ToolCalls)
}
}
func TestOpenAIResponsesProviderChatStreamParsesTypedEvents(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/responses" {
t.Fatalf("expected /v1/responses, got %q", r.URL.Path)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(strings.Join([]string{
`data: {"type":"response.created","response":{"id":"resp_stream","status":"in_progress"}}`,
``,
`data: {"type":"response.reasoning_summary_text.delta","delta":"Need schema. "}`,
``,
`data: {"type":"response.output_text.delta","delta":"Checking "}`,
``,
`data: {"type":"response.output_text.delta","delta":"now."}`,
``,
`data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"get_columns","arguments":""}}`,
``,
`data: {"type":"response.function_call_arguments.delta","item_id":"fc_1","output_index":1,"delta":"{\"table\":"}`,
``,
`data: {"type":"response.function_call_arguments.delta","item_id":"fc_1","output_index":1,"delta":"\"orders\"}"}`,
``,
`data: {"type":"response.function_call_arguments.done","item_id":"fc_1","output_index":1,"arguments":"{\"table\":\"orders\"}"}`,
``,
`data: {"type":"response.completed","response":{"id":"resp_stream","status":"completed","usage":{"input_tokens":5,"output_tokens":4,"total_tokens":9}}}`,
``,
}, "\n")))
}))
defer server.Close()
providerInstance, err := NewOpenAIResponsesProvider(ai.ProviderConfig{
Type: "custom", APIFormat: "openai-responses", APIKey: "sk-test", BaseURL: server.URL + "/v1", Model: "gpt-5.4",
})
if err != nil {
t.Fatalf("create provider: %v", err)
}
var chunks []ai.StreamChunk
err = providerInstance.ChatStream(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "Inspect orders"}},
}, func(chunk ai.StreamChunk) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("stream: %v", err)
}
var content, reasoning strings.Builder
var toolCalls []ai.ToolCall
for _, chunk := range chunks {
content.WriteString(chunk.Content)
reasoning.WriteString(chunk.ReasoningContent)
if len(chunk.ToolCalls) > 0 {
toolCalls = chunk.ToolCalls
}
}
if content.String() != "Checking now." || reasoning.String() != "Need schema. " {
t.Fatalf("unexpected streamed text: content=%q reasoning=%q chunks=%#v", content.String(), reasoning.String(), chunks)
}
if len(toolCalls) != 1 || toolCalls[0].ID != "call_1" || toolCalls[0].Function.Name != "get_columns" || toolCalls[0].Function.Arguments != `{"table":"orders"}` {
t.Fatalf("unexpected streamed tool calls: %#v", toolCalls)
}
if len(chunks) == 0 || !chunks[len(chunks)-1].Done {
t.Fatalf("expected final done chunk, got %#v", chunks)
}
}
func TestBuildOpenAIResponsesInputPreservesAssistantTextAsMessage(t *testing.T) {
input := buildOpenAIResponsesInput([]ai.Message{
{Role: "assistant", Content: "I will inspect the schema first."},
}, "https://api.openai.com/v1")
if len(input) != 1 {
t.Fatalf("expected one assistant message item, got %#v", input)
}
if input[0].Type != "message" || input[0].Role != "assistant" || input[0].Content != "I will inspect the schema first." {
t.Fatalf("unexpected assistant history item: %#v", input[0])
}
}
func TestOpenAIResponsesProviderChatStreamFallsBackToCompletedOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_complete","status":"completed","output":[{"type":"reasoning","summary":[{"type":"summary_text","text":"Use metadata."}]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Done."}]},{"type":"function_call","call_id":"call_complete","name":"get_columns","arguments":"{\"table\":\"orders\"}"}]}}
`))
}))
defer server.Close()
providerInstance, err := NewOpenAIResponsesProvider(ai.ProviderConfig{
Type: "openai", APIFormat: "openai-responses", APIKey: "sk-test", BaseURL: server.URL + "/v1", Model: "gpt-test",
})
if err != nil {
t.Fatalf("create provider: %v", err)
}
var chunks []ai.StreamChunk
err = providerInstance.ChatStream(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "Inspect orders"}},
}, func(chunk ai.StreamChunk) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("stream: %v", err)
}
var content, reasoning strings.Builder
var toolCalls []ai.ToolCall
for _, chunk := range chunks {
content.WriteString(chunk.Content)
reasoning.WriteString(chunk.ReasoningContent)
if len(chunk.ToolCalls) > 0 {
toolCalls = chunk.ToolCalls
}
}
if content.String() != "Done." || reasoning.String() != "Use metadata." {
t.Fatalf("unexpected completed fallback chunks: %#v", chunks)
}
if len(toolCalls) != 1 || toolCalls[0].ID != "call_complete" || toolCalls[0].Function.Name != "get_columns" {
t.Fatalf("unexpected completed fallback tool calls: %#v", toolCalls)
}
if len(chunks) == 0 || !chunks[len(chunks)-1].Done {
t.Fatalf("expected completed event to finish stream: %#v", chunks)
}
}
func TestOpenAIResponsesProviderSessionReplaysRawReasoningAndToolItems(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
defer r.Body.Close()
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if payload["store"] != false || payload["previous_response_id"] != nil {
t.Fatalf("expected local stateless request, got %#v", payload)
}
include, _ := payload["include"].([]any)
if len(include) != 1 || include[0] != "reasoning.encrypted_content" {
t.Fatalf("expected encrypted reasoning include, got %#v", payload["include"])
}
w.Header().Set("Content-Type", "application/json")
if requestCount == 1 {
_, _ = w.Write([]byte(`{
"id":"resp_tool",
"status":"completed",
"output":[
{"id":"rs_1","type":"reasoning","encrypted_content":"opaque-reasoning-token","summary":[{"type":"summary_text","text":"Inspect metadata."}]},
{"id":"msg_1","type":"message","role":"assistant","phase":"commentary","content":[{"type":"output_text","text":"I will inspect it."}]},
{"id":"fc_1","type":"function_call","status":"completed","call_id":"call_1","name":"get_columns","arguments":"{\"table\":\"orders\"}"}
]
}`))
return
}
inputJSON, _ := json.Marshal(payload["input"])
inputText := string(inputJSON)
for _, expected := range []string{
`"encrypted_content":"opaque-reasoning-token"`,
`"phase":"commentary"`,
`"type":"function_call"`,
`"type":"function_call_output"`,
`"call_id":"call_1"`,
} {
if !strings.Contains(inputText, expected) {
t.Fatalf("expected replayed input to contain %s, got %s", expected, inputText)
}
}
_, _ = w.Write([]byte(`{
"id":"resp_final",
"status":"completed",
"output":[
{"id":"msg_2","type":"message","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"The table has an id column."}]}
]
}`))
}))
defer server.Close()
providerInstance, err := NewOpenAIResponsesProvider(ai.ProviderConfig{
Type: "openai", APIFormat: "openai-responses", APIKey: "sk-test", BaseURL: server.URL + "/v1", Model: "gpt-test",
})
if err != nil {
t.Fatalf("create provider: %v", err)
}
sessionProvider, ok := providerInstance.(SessionChatProvider)
if !ok {
t.Fatalf("expected SessionChatProvider, got %T", providerInstance)
}
first, state, err := sessionProvider.ChatWithState(context.Background(), nil, ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "Inspect orders"}},
})
if err != nil {
t.Fatalf("first response: %v", err)
}
if first.Content != "I will inspect it." || len(first.ToolCalls) != 1 || len(state) == 0 {
t.Fatalf("unexpected first response/state: response=%#v state=%s", first, state)
}
second, nextState, err := sessionProvider.ChatWithState(context.Background(), state, ai.ChatRequest{
Messages: []ai.Message{{Role: "tool", ToolCallID: "call_1", Content: `{"columns":["id"]}`}},
})
if err != nil {
t.Fatalf("second response: %v", err)
}
if second.Content != "The table has an id column." || len(nextState) == 0 || requestCount != 2 {
t.Fatalf("unexpected second response/state: response=%#v state=%s requests=%d", second, nextState, requestCount)
}
}
func TestOpenAIResponsesProviderStreamStateKeepsCompletedRawOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_stream_state","status":"completed","output":[{"id":"rs_stream","type":"reasoning","encrypted_content":"stream-secret","summary":[]},{"id":"msg_stream","type":"message","role":"assistant","phase":"commentary","content":[{"type":"output_text","text":"Checking."}]},{"id":"fc_stream","type":"function_call","call_id":"call_stream","name":"get_columns","arguments":"{}"}]}}
`))
}))
defer server.Close()
providerInstance, err := NewOpenAIResponsesProvider(ai.ProviderConfig{
Type: "openai", APIFormat: "openai-responses", APIKey: "sk-test", BaseURL: server.URL + "/v1", Model: "gpt-test",
})
if err != nil {
t.Fatalf("create provider: %v", err)
}
sessionProvider, ok := providerInstance.(SessionStreamProvider)
if !ok {
t.Fatalf("expected SessionStreamProvider, got %T", providerInstance)
}
state, err := sessionProvider.ChatStreamWithState(context.Background(), nil, ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "Inspect orders"}},
}, func(ai.StreamChunk) {})
if err != nil {
t.Fatalf("stream response: %v", err)
}
stateText := string(state)
for _, expected := range []string{"stream-secret", `"phase":"commentary"`, `"type":"function_call"`} {
if !strings.Contains(stateText, expected) {
t.Fatalf("expected stream state to preserve %q, got %s", expected, stateText)
}
}
}
func TestOpenAIResponsesProviderPreservesExplicitResponsesEndpoint(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/responses" {
t.Fatalf("expected explicit responses endpoint to stay unchanged, got %q", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id":"resp_explicit_endpoint",
"status":"completed",
"output":[
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"pong"}]}
],
"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
}`))
}))
defer server.Close()
providerInstance, err := NewOpenAIResponsesProvider(ai.ProviderConfig{
Type: "custom", APIFormat: "openai-responses", APIKey: "sk-test", BaseURL: server.URL + "/v1/responses", Model: "gpt-test",
})
if err != nil {
t.Fatalf("create provider: %v", err)
}
response, err := providerInstance.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
})
if err != nil {
t.Fatalf("chat: %v", err)
}
if response.Content != "pong" {
t.Fatalf("unexpected response: %#v", response)
}
}
func TestOpenAIResponsesProviderChatReportsAPIAndEmptyOutputErrors(t *testing.T) {
tests := []struct {
name string
body string
want string
}{
{
name: "api_error",
body: `{"error":{"message":"permission denied"}}`,
want: "permission denied",
},
{
name: "empty_output",
body: `{"id":"resp_empty","status":"completed","output":[]}`,
want: "empty response",
},
{
name: "incomplete_output",
body: `{"id":"resp_incomplete","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"partial"}]}]}`,
want: "max_output_tokens",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(tt.body))
}))
defer server.Close()
providerInstance, err := NewOpenAIResponsesProvider(ai.ProviderConfig{
Type: "custom", APIFormat: "openai-responses", APIKey: "sk-test", BaseURL: server.URL + "/v1", Model: "gpt-test",
})
if err != nil {
t.Fatalf("create provider: %v", err)
}
_, err = providerInstance.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
})
if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tt.want)) {
t.Fatalf("expected error containing %q, got %v", tt.want, err)
}
})
}
}
func TestOpenAIResponsesProviderChatStreamReportsFailedAndErrorEvents(t *testing.T) {
tests := []struct {
name string
event string
want string
}{
{
name: "response_failed",
event: `data: {"type":"response.failed","response":{"id":"resp_failed","status":"failed","error":{"message":"rate limited"}}}`,
want: "rate limited",
},
{
name: "error_event",
event: `data: {"type":"error","code":"server_error","message":"upstream unavailable"}`,
want: "upstream unavailable",
},
{
name: "response_incomplete",
event: `data: {"type":"response.incomplete","response":{"id":"resp_incomplete","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}}`,
want: "max_output_tokens",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(tt.event + "\n\n"))
}))
defer server.Close()
providerInstance, err := NewOpenAIResponsesProvider(ai.ProviderConfig{
Type: "custom", APIFormat: "openai-responses", APIKey: "sk-test", BaseURL: server.URL + "/v1", Model: "gpt-test",
})
if err != nil {
t.Fatalf("create provider: %v", err)
}
err = providerInstance.ChatStream(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
}, func(ai.StreamChunk) {})
if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tt.want)) {
t.Fatalf("expected stream error containing %q, got %v", tt.want, err)
}
})
}
}
func TestOpenAIResponsesProviderChatRetriesWithoutToolsOnHTTP400(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request: %v", err)
}
defer r.Body.Close()
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if _, hasTools := payload["tools"]; hasTools {
http.Error(w, `{"error":{"message":"tools unsupported"}}`, http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id":"resp_without_tools",
"status":"completed",
"output":[
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"pong"}]}
],
"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
}`))
}))
defer server.Close()
providerInstance, err := NewOpenAIResponsesProvider(ai.ProviderConfig{
Type: "custom", APIFormat: "openai-responses", APIKey: "sk-test", BaseURL: server.URL + "/v1", Model: "gpt-test",
})
if err != nil {
t.Fatalf("create provider: %v", err)
}
response, err := providerInstance.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
Tools: []ai.Tool{{
Type: "function",
Function: ai.ToolFunction{
Name: "inspect_table_schema",
Parameters: map[string]any{"type": "object"},
},
}},
})
if err != nil {
t.Fatalf("expected tools fallback to succeed, got %v", err)
}
if requestCount != 3 {
t.Fatalf("expected include fallback before the retry without tools, got %d requests", requestCount)
}
if response.Content != "pong" {
t.Fatalf("unexpected fallback response: %#v", response)
}
}
func TestOpenAIResponsesProviderChatRetriesWithoutUnsupportedIncludeOnHTTP400(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
defer r.Body.Close()
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if _, hasInclude := payload["include"]; hasInclude {
http.Error(w, `{"error":{"message":"include unsupported"}}`, http.StatusBadRequest)
return
}
if _, hasTools := payload["tools"]; !hasTools {
t.Fatalf("expected include fallback to preserve tools, got %#v", payload)
}
inputJSON, _ := json.Marshal(payload["input"])
if !strings.Contains(string(inputJSON), `"type":"input_image"`) {
t.Fatalf("expected include fallback to preserve images, got %s", inputJSON)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"resp_without_include","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"pong"}]}]}`))
}))
defer server.Close()
providerInstance, err := NewOpenAIResponsesProvider(ai.ProviderConfig{
Type: "custom", APIFormat: "openai-responses", APIKey: "sk-test", BaseURL: server.URL + "/v1", Model: "gpt-test",
})
if err != nil {
t.Fatalf("create provider: %v", err)
}
response, err := providerInstance.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping", Images: []string{"data:image/png;base64,abc"}}},
Tools: []ai.Tool{{
Type: "function",
Function: ai.ToolFunction{
Name: "inspect_table_schema",
Parameters: map[string]any{"type": "object"},
},
}},
})
if err != nil {
t.Fatalf("expected include fallback to succeed, got %v", err)
}
if response.Content != "pong" || requestCount != 2 {
t.Fatalf("unexpected include fallback result: response=%#v requests=%d", response, requestCount)
}
}
func TestOpenAIResponsesProviderStreamWithoutCompletedReturnsErrorAndPreservesSessionState(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer server.Close()
providerInstance, err := NewOpenAIResponsesProvider(ai.ProviderConfig{
Type: "openai", APIFormat: "openai-responses", APIKey: "sk-test", BaseURL: server.URL + "/v1", Model: "gpt-test",
})
if err != nil {
t.Fatalf("create provider: %v", err)
}
sessionProvider := providerInstance.(SessionStreamProvider)
oldState := json.RawMessage(`{"input":[{"type":"message","role":"user","content":"old"}]}`)
nextState, err := sessionProvider.ChatStreamWithState(context.Background(), oldState, ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "new"}},
}, func(ai.StreamChunk) {})
if err == nil || !strings.Contains(err.Error(), "response.completed") {
t.Fatalf("expected missing response.completed error, got %v", err)
}
if string(nextState) != string(oldState) {
t.Fatalf("expected failed stream to preserve old state, got %s", nextState)
}
}
func TestOpenAIResponsesProviderEmptyCompletedResponseReturnsErrorAndPreservesSessionState(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_empty\",\"status\":\"completed\",\"output\":[]}}\n\n"))
}))
defer server.Close()
providerInstance, err := NewOpenAIResponsesProvider(ai.ProviderConfig{
Type: "openai", APIFormat: "openai-responses", APIKey: "sk-test", BaseURL: server.URL + "/v1", Model: "gpt-test",
})
if err != nil {
t.Fatalf("create provider: %v", err)
}
sessionProvider := providerInstance.(SessionStreamProvider)
oldState := json.RawMessage(`{"input":[{"type":"message","role":"user","content":"old"}]}`)
nextState, err := sessionProvider.ChatStreamWithState(context.Background(), oldState, ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "new"}},
}, func(ai.StreamChunk) {})
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "empty response") {
t.Fatalf("expected empty completed response error, got %v", err)
}
if string(nextState) != string(oldState) {
t.Fatalf("expected empty completed response to preserve old state, got %s", nextState)
}
}
func TestOpenAIResponsesProviderUsesConfiguredMaxOutputTokens(t *testing.T) {
var received map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
if err := json.NewDecoder(r.Body).Decode(&received); err != nil {
t.Fatalf("decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"resp_1","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"pong"}]}]}`))
}))
defer server.Close()
providerInstance, err := NewOpenAIResponsesProvider(ai.ProviderConfig{
Type: "openai", APIFormat: "openai-responses", APIKey: "sk-test", BaseURL: server.URL + "/v1", Model: "gpt-test", MaxTokens: 321,
})
if err != nil {
t.Fatalf("create provider: %v", err)
}
if _, err := providerInstance.Chat(context.Background(), ai.ChatRequest{Messages: []ai.Message{{Role: "user", Content: "ping"}}}); err != nil {
t.Fatalf("chat: %v", err)
}
if received["max_output_tokens"] != float64(321) {
t.Fatalf("expected configured max_output_tokens, got %#v", received["max_output_tokens"])
}
}
func TestProviderFactoriesSelectOpenAIResponsesProtocol(t *testing.T) {
config := ai.ProviderConfig{
Type: "custom", APIFormat: "openai-responses", APIKey: "sk-test", BaseURL: "https://api.example.com/v1", Model: "gpt-test",
}
customProvider, err := NewCustomProvider(config)
if err != nil {
t.Fatalf("create custom provider: %v", err)
}
custom, ok := customProvider.(*CustomProvider)
if !ok {
t.Fatalf("expected CustomProvider, got %T", customProvider)
}
if _, ok := custom.inner.(*OpenAIResponsesProvider); !ok {
t.Fatalf("expected OpenAIResponsesProvider inner, got %T", custom.inner)
}
directProvider, err := NewProvider(ai.ProviderConfig{
Type: "openai", APIFormat: "openai-responses", APIKey: "sk-test", Model: "gpt-test",
})
if err != nil {
t.Fatalf("create direct provider: %v", err)
}
if _, ok := directProvider.(*OpenAIResponsesProvider); !ok {
t.Fatalf("expected direct OpenAIResponsesProvider, got %T", directProvider)
}
}

View File

@@ -58,6 +58,11 @@ func TestNormalizeOpenAICompatibleBaseURL(t *testing.T) {
raw: "https://api.openai.com/v1/chat/completions",
want: "https://api.openai.com/v1",
},
{
name: "strips responses suffix before normalizing",
raw: "https://api.openai.com/v1/responses",
want: "https://api.openai.com/v1",
},
{
name: "strips models suffix before normalizing",
raw: "https://ark.cn-beijing.volces.com/api/coding/v3/models",

View File

@@ -12,6 +12,9 @@ func NewProvider(config ai.ProviderConfig) (Provider, error) {
providerType := strings.ToLower(strings.TrimSpace(config.Type))
switch providerType {
case "openai":
if strings.EqualFold(strings.TrimSpace(config.APIFormat), "openai-responses") {
return NewOpenAIResponsesProvider(config)
}
return NewOpenAIProvider(config)
case "anthropic":
return NewAnthropicProvider(config)

View File

@@ -63,7 +63,7 @@ func ResolveThinkingProfile(providerType, apiFormat, baseURL, model string) Thin
if ptype == "anthropic" || format == "anthropic" {
return ThinkingProfileAnthropic
}
if ptype == "openai" || format == "" || format == "openai" {
if ptype == "openai" || format == "" || format == "openai" || format == "openai-responses" {
return ThinkingProfileOpenAI
}
return ThinkingProfileGeneric

View File

@@ -651,7 +651,11 @@ func formatProviderHTTPBody(body []byte) string {
func normalizedProviderType(config ai.ProviderConfig) string {
providerType := strings.ToLower(strings.TrimSpace(config.Type))
if providerType == "custom" && strings.TrimSpace(config.APIFormat) != "" {
return strings.ToLower(strings.TrimSpace(config.APIFormat))
apiFormat := strings.ToLower(strings.TrimSpace(config.APIFormat))
if apiFormat == "openai-responses" {
return "openai"
}
return apiFormat
}
return providerType
}

View File

@@ -37,6 +37,17 @@ func TestResolveModelsURL_UsesOpenAIModelsEndpointForOpenAICompatibleProvider(t
}
}
func TestResolveModelsURL_UsesOpenAIModelsEndpointForResponsesProvider(t *testing.T) {
url := resolveModelsURL(ai.ProviderConfig{
Type: "custom",
APIFormat: "openai-responses",
BaseURL: "https://api.openai.com/v1",
})
if url != "https://api.openai.com/v1/models" {
t.Fatalf("expected responses provider to share OpenAI models endpoint, got %q", url)
}
}
func TestResolveModelsURL_UsesVersionedVolcengineCodingPlanPath(t *testing.T) {
url := resolveModelsURL(ai.ProviderConfig{
Type: "openai",

View File

@@ -85,6 +85,7 @@ type StreamChunk struct {
// - Anthropic: off | low | medium | high | xhigh | max
// - DeepSeek: off | low | medium | high
// - Gemini: off | minimal | low | medium | high
//
// 不同供应商再映射到 reasoning_effort / thinking / output_config.effort / thinking_level 等字段。
type ThinkingIntensity string
@@ -107,7 +108,7 @@ type ProviderConfig struct {
Model string `json:"model"`
InlineCompletionModel string `json:"inlineCompletionModel,omitempty"`
Models []string `json:"models,omitempty"`
APIFormat string `json:"apiFormat,omitempty"` // custom 专用: openai | anthropic | gemini | cursor-agent | claude-cli | codebuddy-cli
APIFormat string `json:"apiFormat,omitempty"` // openai | openai-responses | anthropic | gemini | cursor-agent | claude-cli | codebuddy-cli
Headers map[string]string `json:"headers,omitempty"`
MaxTokens int `json:"maxTokens"`
Temperature float64 `json:"temperature"`