-
-
Notifications
You must be signed in to change notification settings - Fork 567
Add Responses API support and model-level routing #205
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
ca19450
6ecf29a
97bdaec
72abf17
5582512
eb06825
a1edfd5
34cbde3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- Route gpt-5.3-codex requests through the Responses API since it doesn't work with chat/completions - Add model(level) suffix parsing for reasoning effort control (e.g. gpt-5.3-codex(high), claude-opus-4.6(medium)) - Add direct /v1/responses endpoint for passthrough access - Expand /v1/models to list level-suffixed variants - Pass through Claude thinking config when level is specified - Fix translateModelName to not clobber 4.6 model names
- Loading branch information
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| export const MODEL_LEVELS = ["low", "medium", "high", "xhigh"] as const | ||
|
|
||
| export type ModelLevel = (typeof MODEL_LEVELS)[number] | ||
|
|
||
| export const MODEL_LEVEL_VARIANTS = { | ||
| "gpt-5.3-codex": MODEL_LEVELS, | ||
| "claude-opus-4.6": ["low", "medium", "high"], | ||
| "claude-opus-4.6-fast": ["low", "medium", "high"], | ||
| "claude-sonnet-4.6": ["low", "medium", "high"], | ||
| } as const satisfies Record<string, ReadonlyArray<ModelLevel>> | ||
|
|
||
| export const parseModelNameWithLevel = ( | ||
| model: string, | ||
| ): { | ||
| baseModel: string | ||
| level: ModelLevel | undefined | ||
| } => { | ||
| const match = model.match(/^(.+)\((low|medium|high|xhigh)\)$/) | ||
| if (!match) { | ||
| return { | ||
| baseModel: model, | ||
| level: undefined, | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| baseModel: match[1], | ||
| level: match[2] as ModelLevel, | ||
| } | ||
| } | ||
|
|
||
| export const isCodexResponsesModel = (model: string): boolean => | ||
| model === "gpt-5.3-codex" | ||
|
|
||
| export const isClaudeThinkingModel = (model: string): boolean => | ||
| model === "claude-opus-4.6" | ||
| || model === "claude-opus-4.6-fast" | ||
| || model === "claude-sonnet-4.6" |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,253 @@ | ||||||||||||||||||||||||||||||||||||
| import type { SSEMessage } from "hono/streaming" | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| import { randomUUID } from "node:crypto" | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| import type { | ||||||||||||||||||||||||||||||||||||
| ChatCompletionChunk, | ||||||||||||||||||||||||||||||||||||
| ChatCompletionResponse, | ||||||||||||||||||||||||||||||||||||
| ChatCompletionsPayload, | ||||||||||||||||||||||||||||||||||||
| ContentPart, | ||||||||||||||||||||||||||||||||||||
| Message, | ||||||||||||||||||||||||||||||||||||
| ToolCall, | ||||||||||||||||||||||||||||||||||||
| } from "~/services/copilot/create-chat-completions" | ||||||||||||||||||||||||||||||||||||
| import type { | ||||||||||||||||||||||||||||||||||||
| ResponseInputContentPart, | ||||||||||||||||||||||||||||||||||||
| ResponseInputMessage, | ||||||||||||||||||||||||||||||||||||
| ResponsesApiResponse, | ||||||||||||||||||||||||||||||||||||
| ResponsesFunctionCall, | ||||||||||||||||||||||||||||||||||||
| ResponsesOutputContentPart, | ||||||||||||||||||||||||||||||||||||
| ResponsesOutputItem, | ||||||||||||||||||||||||||||||||||||
| ResponsesPayload, | ||||||||||||||||||||||||||||||||||||
| } from "~/services/copilot/create-responses" | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| export function translateChatCompletionsToResponses( | ||||||||||||||||||||||||||||||||||||
| payload: ChatCompletionsPayload, | ||||||||||||||||||||||||||||||||||||
| ): ResponsesPayload { | ||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||
| model: payload.model, | ||||||||||||||||||||||||||||||||||||
| input: payload.messages.map((message) => translateMessage(message)), | ||||||||||||||||||||||||||||||||||||
| stream: payload.stream, | ||||||||||||||||||||||||||||||||||||
| temperature: payload.temperature, | ||||||||||||||||||||||||||||||||||||
| top_p: payload.top_p, | ||||||||||||||||||||||||||||||||||||
| max_output_tokens: payload.max_tokens, | ||||||||||||||||||||||||||||||||||||
| stop: payload.stop, | ||||||||||||||||||||||||||||||||||||
| tools: payload.tools as Array<unknown> | null | undefined, | ||||||||||||||||||||||||||||||||||||
| tool_choice: payload.tool_choice, | ||||||||||||||||||||||||||||||||||||
| user: payload.user, | ||||||||||||||||||||||||||||||||||||
| reasoning_effort: payload.reasoning_effort, | ||||||||||||||||||||||||||||||||||||
| reasoning: | ||||||||||||||||||||||||||||||||||||
| payload.reasoning | ||||||||||||||||||||||||||||||||||||
| ?? (payload.reasoning_effort ? | ||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||
| effort: payload.reasoning_effort, | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| : undefined), | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| export function translateResponsesToChatCompletions( | ||||||||||||||||||||||||||||||||||||
| response: ResponsesApiResponse, | ||||||||||||||||||||||||||||||||||||
| ): ChatCompletionResponse { | ||||||||||||||||||||||||||||||||||||
| const outputItems = response.output ?? [] | ||||||||||||||||||||||||||||||||||||
| const messageContent = extractOutputText(outputItems, response.output_text) | ||||||||||||||||||||||||||||||||||||
| const toolCalls = extractToolCalls(outputItems) | ||||||||||||||||||||||||||||||||||||
| const completionTokens = response.usage?.output_tokens ?? 0 | ||||||||||||||||||||||||||||||||||||
| const promptTokens = response.usage?.input_tokens ?? 0 | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||
| id: response.id, | ||||||||||||||||||||||||||||||||||||
| object: "chat.completion", | ||||||||||||||||||||||||||||||||||||
| created: response.created_at ?? Math.floor(Date.now() / 1000), | ||||||||||||||||||||||||||||||||||||
| model: response.model, | ||||||||||||||||||||||||||||||||||||
| choices: [ | ||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||
| index: 0, | ||||||||||||||||||||||||||||||||||||
| message: { | ||||||||||||||||||||||||||||||||||||
| role: "assistant", | ||||||||||||||||||||||||||||||||||||
| content: messageContent.length > 0 ? messageContent : null, | ||||||||||||||||||||||||||||||||||||
| ...(toolCalls.length > 0 && { tool_calls: toolCalls }), | ||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||
| logprobs: null, | ||||||||||||||||||||||||||||||||||||
| finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop", | ||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||||
| usage: { | ||||||||||||||||||||||||||||||||||||
| prompt_tokens: promptTokens, | ||||||||||||||||||||||||||||||||||||
| completion_tokens: completionTokens, | ||||||||||||||||||||||||||||||||||||
| total_tokens: | ||||||||||||||||||||||||||||||||||||
| response.usage?.total_tokens ?? promptTokens + completionTokens, | ||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| export async function* translateResponsesStreamToChatStream( | ||||||||||||||||||||||||||||||||||||
| responseStream: AsyncIterable<{ data?: string }>, | ||||||||||||||||||||||||||||||||||||
| model: string, | ||||||||||||||||||||||||||||||||||||
| ): AsyncGenerator<SSEMessage> { | ||||||||||||||||||||||||||||||||||||
| const completionId = randomUUID() | ||||||||||||||||||||||||||||||||||||
| const created = Math.floor(Date.now() / 1000) | ||||||||||||||||||||||||||||||||||||
| let hasEmittedContent = false | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| for await (const rawEvent of responseStream) { | ||||||||||||||||||||||||||||||||||||
| if (rawEvent.data === "[DONE]") { | ||||||||||||||||||||||||||||||||||||
| const endChunk: ChatCompletionChunk = { | ||||||||||||||||||||||||||||||||||||
| id: completionId, | ||||||||||||||||||||||||||||||||||||
| object: "chat.completion.chunk", | ||||||||||||||||||||||||||||||||||||
| created, | ||||||||||||||||||||||||||||||||||||
| model, | ||||||||||||||||||||||||||||||||||||
| choices: [ | ||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||
| index: 0, | ||||||||||||||||||||||||||||||||||||
| delta: {}, | ||||||||||||||||||||||||||||||||||||
| finish_reason: "stop", | ||||||||||||||||||||||||||||||||||||
| logprobs: null, | ||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| yield { data: JSON.stringify(endChunk) } | ||||||||||||||||||||||||||||||||||||
| yield { data: "[DONE]" } | ||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| if (!rawEvent.data) { | ||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const parsedEvent = JSON.parse(rawEvent.data) as { | ||||||||||||||||||||||||||||||||||||
| type?: string | ||||||||||||||||||||||||||||||||||||
| delta?: string | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||||||||
| parsedEvent.type === "response.output_text.delta" | ||||||||||||||||||||||||||||||||||||
| && typeof parsedEvent.delta === "string" | ||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||
| const chunk: ChatCompletionChunk = { | ||||||||||||||||||||||||||||||||||||
| id: completionId, | ||||||||||||||||||||||||||||||||||||
| object: "chat.completion.chunk", | ||||||||||||||||||||||||||||||||||||
| created, | ||||||||||||||||||||||||||||||||||||
| model, | ||||||||||||||||||||||||||||||||||||
| choices: [ | ||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||
| index: 0, | ||||||||||||||||||||||||||||||||||||
| delta: { | ||||||||||||||||||||||||||||||||||||
| ...(hasEmittedContent ? {} : { role: "assistant" }), | ||||||||||||||||||||||||||||||||||||
| content: parsedEvent.delta, | ||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||
| finish_reason: null, | ||||||||||||||||||||||||||||||||||||
| logprobs: null, | ||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| hasEmittedContent = true | ||||||||||||||||||||||||||||||||||||
| yield { data: JSON.stringify(chunk) } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+84
to
+341
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| function translateMessage(message: Message): ResponseInputMessage { | ||||||||||||||||||||||||||||||||||||
| let content: ResponseInputMessage["content"] | ||||||||||||||||||||||||||||||||||||
| if (typeof message.content === "string") { | ||||||||||||||||||||||||||||||||||||
| content = message.content | ||||||||||||||||||||||||||||||||||||
| } else if (message.content === null) { | ||||||||||||||||||||||||||||||||||||
| content = "" | ||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||
| content = message.content.map((part) => translateContentPart(part)) | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||
| role: message.role, | ||||||||||||||||||||||||||||||||||||
| content, | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+353
to
+365
|
||||||||||||||||||||||||||||||||||||
| return { | |
| role: message.role, | |
| content, | |
| } | |
| const translated: ResponseInputMessage = { | |
| role: message.role, | |
| content, | |
| ...(message as any).name ? { name: (message as any).name } : {}, | |
| ...(message as any).tool_call_id | |
| ? { tool_call_id: (message as any).tool_call_id } | |
| : {}, | |
| ...(Array.isArray((message as any).tool_calls) | |
| ? { tool_calls: (message as any).tool_calls } | |
| : {}), | |
| } as ResponseInputMessage | |
| return translated |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
translateResponsesStreamToChatStream currently ignores all Responses streaming event types except
response.output_text.delta, so streamed tool/function call events (and any other deltas) will be dropped. This breaks the PR’s claim that tool calls are translated for streaming and can lead to clients never receivingtool_callsdeltas / correctfinish_reason. Extend the stream translator to handle function/tool-call related event types and emit the corresponding Chat Completions chunk deltas (including a final chunk withfinish_reason: "tool_calls"when applicable).