mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 08:43:34 +08:00
✨ feat(ai): 支持 OpenAI Responses 协议
- 设置中心为 OpenAI 预设增加 Chat 与 Responses 协议选择 - 实现 Responses API 同步、流式、推理摘要及工具调用适配 - 支持会话状态回放与上游能力不兼容时的自动降级 - 完善地址、模型列表和思考强度识别并补充回归测试
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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-responses;custom 支持 openai/anthropic/gemini/CLI 等格式
|
||||
headers?: Record<string, string>;
|
||||
maxTokens: number;
|
||||
temperature: number;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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")]
|
||||
}
|
||||
|
||||
707
internal/ai/provider/openai_responses.go
Normal file
707
internal/ai/provider/openai_responses.go
Normal 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
|
||||
}
|
||||
716
internal/ai/provider/openai_responses_test.go
Normal file
716
internal/ai/provider/openai_responses_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user