Merge origin/main into provider-setup-wizard
This commit is contained in:
@@ -154,7 +154,10 @@ export async function getAnthropicClient({
|
||||
fetch: resolvedFetch,
|
||||
}),
|
||||
}
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) {
|
||||
if (
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||
) {
|
||||
const { createOpenAIShimClient } = await import('./openaiShim.js')
|
||||
return createOpenAIShimClient({
|
||||
defaultHeaders,
|
||||
|
||||
@@ -14,8 +14,15 @@
|
||||
* OPENAI_BASE_URL=http://... — base URL (default: https://api.openai.com/v1)
|
||||
* OPENAI_MODEL=gpt-4o — default model override
|
||||
* CODEX_API_KEY / ~/.codex/auth.json — Codex auth for codexplan/codexspark
|
||||
*
|
||||
* GitHub Models (models.github.ai), OpenAI-compatible:
|
||||
* CLAUDE_CODE_USE_GITHUB=1 — enable GitHub inference (no need for USE_OPENAI)
|
||||
* GITHUB_TOKEN or GH_TOKEN — PAT with models access (mapped to Bearer auth)
|
||||
* OPENAI_MODEL — optional; use github:copilot or openai/gpt-4.1 style IDs
|
||||
*/
|
||||
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js'
|
||||
import {
|
||||
codexStreamToAnthropic,
|
||||
collectCodexCompletedResponse,
|
||||
@@ -31,6 +38,25 @@ import {
|
||||
} from './providerConfig.js'
|
||||
import { redactSecretValueForDisplay } from '../../utils/providerProfile.js'
|
||||
|
||||
const GITHUB_MODELS_DEFAULT_BASE = 'https://models.github.ai/inference'
|
||||
const GITHUB_API_VERSION = '2022-11-28'
|
||||
const GITHUB_429_MAX_RETRIES = 3
|
||||
const GITHUB_429_BASE_DELAY_SEC = 1
|
||||
const GITHUB_429_MAX_DELAY_SEC = 32
|
||||
|
||||
function isGithubModelsMode(): boolean {
|
||||
return isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||
}
|
||||
|
||||
function formatRetryAfterHint(response: Response): string {
|
||||
const ra = response.headers.get('retry-after')
|
||||
return ra ? ` (Retry-After: ${ra})` : ''
|
||||
}
|
||||
|
||||
function sleepMs(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types — minimal subset of Anthropic SDK types we need to produce
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -236,28 +262,66 @@ function normalizeSchemaForOpenAI(
|
||||
schema: Record<string, unknown>,
|
||||
strict = true,
|
||||
): Record<string, unknown> {
|
||||
if (schema.type !== 'object' || !schema.properties) return schema
|
||||
const properties = schema.properties as Record<string, unknown>
|
||||
const existingRequired = Array.isArray(schema.required) ? schema.required as string[] : []
|
||||
// OpenAI strict mode requires every property to be listed in required[].
|
||||
// Gemini rejects schemas where required[] contains keys absent from properties,
|
||||
// so only promote keys that actually exist in properties.
|
||||
if (strict) {
|
||||
const allKeys = Object.keys(properties)
|
||||
const required = Array.from(new Set([...existingRequired, ...allKeys]))
|
||||
return { ...schema, required }
|
||||
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
||||
return (schema ?? {}) as Record<string, unknown>
|
||||
}
|
||||
// For Gemini: keep only existing required keys that are present in properties
|
||||
const required = existingRequired.filter(k => k in properties)
|
||||
return { ...schema, required }
|
||||
|
||||
const record = { ...schema }
|
||||
|
||||
if (record.type === 'object' && record.properties) {
|
||||
const properties = record.properties as Record<string, Record<string, unknown>>
|
||||
const existingRequired = Array.isArray(record.required) ? record.required as string[] : []
|
||||
|
||||
// Recurse into each property
|
||||
const normalizedProps: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
normalizedProps[key] = normalizeSchemaForOpenAI(
|
||||
value as Record<string, unknown>,
|
||||
strict,
|
||||
)
|
||||
}
|
||||
record.properties = normalizedProps
|
||||
|
||||
if (strict) {
|
||||
// OpenAI strict mode requires every property to be listed in required[]
|
||||
const allKeys = Object.keys(normalizedProps)
|
||||
record.required = Array.from(new Set([...existingRequired, ...allKeys]))
|
||||
// OpenAI strict mode requires additionalProperties: false on all object
|
||||
// schemas — override unconditionally to ensure nested objects comply.
|
||||
record.additionalProperties = false
|
||||
} else {
|
||||
// For Gemini: keep only existing required keys that are present in properties
|
||||
record.required = existingRequired.filter(k => k in normalizedProps)
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into array items
|
||||
if ('items' in record) {
|
||||
if (Array.isArray(record.items)) {
|
||||
record.items = (record.items as unknown[]).map(
|
||||
item => normalizeSchemaForOpenAI(item as Record<string, unknown>, strict),
|
||||
)
|
||||
} else {
|
||||
record.items = normalizeSchemaForOpenAI(record.items as Record<string, unknown>, strict)
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into combinators
|
||||
for (const key of ['anyOf', 'oneOf', 'allOf'] as const) {
|
||||
if (key in record && Array.isArray(record[key])) {
|
||||
record[key] = (record[key] as unknown[]).map(
|
||||
item => normalizeSchemaForOpenAI(item as Record<string, unknown>, strict),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
function convertTools(
|
||||
tools: Array<{ name: string; description?: string; input_schema?: Record<string, unknown> }>,
|
||||
): OpenAITool[] {
|
||||
const isGemini =
|
||||
process.env.CLAUDE_CODE_USE_GEMINI === '1' ||
|
||||
process.env.CLAUDE_CODE_USE_GEMINI === 'true'
|
||||
const isGemini = isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
|
||||
|
||||
return tools
|
||||
.filter(t => t.name !== 'ToolSearchTool') // Not relevant for OpenAI
|
||||
@@ -343,7 +407,7 @@ async function* openaiStreamToAnthropic(
|
||||
): AsyncGenerator<AnthropicStreamEvent> {
|
||||
const messageId = makeMessageId()
|
||||
let contentBlockIndex = 0
|
||||
const activeToolCalls = new Map<number, { id: string; name: string; index: number }>()
|
||||
const activeToolCalls = new Map<number, { id: string; name: string; index: number; jsonBuffer: string }>()
|
||||
let hasEmittedContentStart = false
|
||||
let lastStopReason: 'tool_use' | 'max_tokens' | 'end_turn' | null = null
|
||||
let hasEmittedFinalUsage = false
|
||||
@@ -375,15 +439,16 @@ async function* openaiStreamToAnthropic(
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed === 'data: [DONE]') continue
|
||||
if (!trimmed.startsWith('data: ')) continue
|
||||
@@ -437,6 +502,7 @@ async function* openaiStreamToAnthropic(
|
||||
id: tc.id,
|
||||
name: tc.function.name,
|
||||
index: toolBlockIndex,
|
||||
jsonBuffer: tc.function.arguments ?? '',
|
||||
})
|
||||
|
||||
yield {
|
||||
@@ -467,6 +533,9 @@ async function* openaiStreamToAnthropic(
|
||||
// Continuation of existing tool call
|
||||
const active = activeToolCalls.get(tc.index)
|
||||
if (active) {
|
||||
if (tc.function.arguments) {
|
||||
active.jsonBuffer += tc.function.arguments
|
||||
}
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: active.index,
|
||||
@@ -494,6 +563,36 @@ async function* openaiStreamToAnthropic(
|
||||
}
|
||||
// Close active tool calls
|
||||
for (const [, tc] of activeToolCalls) {
|
||||
let suffixToAdd = ''
|
||||
if (tc.jsonBuffer) {
|
||||
try {
|
||||
JSON.parse(tc.jsonBuffer)
|
||||
} catch {
|
||||
const str = tc.jsonBuffer.trimEnd()
|
||||
const combinations = [
|
||||
'}', '"}', ']}', '"]}', '}}', '"}}', ']}}', '"]}}', '"]}]}', '}]}'
|
||||
]
|
||||
for (const combo of combinations) {
|
||||
try {
|
||||
JSON.parse(str + combo)
|
||||
suffixToAdd = combo
|
||||
break
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (suffixToAdd) {
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: tc.index,
|
||||
delta: {
|
||||
type: 'input_json_delta',
|
||||
partial_json: suffixToAdd,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
yield { type: 'content_block_stop', index: tc.index }
|
||||
}
|
||||
|
||||
@@ -529,6 +628,9 @@ async function* openaiStreamToAnthropic(
|
||||
hasEmittedFinalUsage = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
|
||||
yield { type: 'message_stop' }
|
||||
@@ -680,6 +782,12 @@ class OpenAIShimMessages {
|
||||
body.stream_options = { include_usage: true }
|
||||
}
|
||||
|
||||
const isGithub = isGithubModelsMode()
|
||||
if (isGithub && body.max_completion_tokens !== undefined) {
|
||||
body.max_tokens = body.max_completion_tokens
|
||||
delete body.max_completion_tokens
|
||||
}
|
||||
|
||||
if (params.temperature !== undefined) body.temperature = params.temperature
|
||||
if (params.top_p !== undefined) body.top_p = params.top_p
|
||||
|
||||
@@ -729,6 +837,11 @@ class OpenAIShimMessages {
|
||||
}
|
||||
}
|
||||
|
||||
if (isGithub) {
|
||||
headers.Accept = 'application/vnd.github.v3+json'
|
||||
headers['X-GitHub-Api-Version'] = GITHUB_API_VERSION
|
||||
}
|
||||
|
||||
// Build the chat completions URL
|
||||
// Azure Cognitive Services / Azure OpenAI require a deployment-specific path
|
||||
// and an api-version query parameter.
|
||||
@@ -751,19 +864,42 @@ class OpenAIShimMessages {
|
||||
chatCompletionsUrl = `${request.baseUrl}/chat/completions`
|
||||
}
|
||||
|
||||
const response = await fetch(chatCompletionsUrl, {
|
||||
method: 'POST',
|
||||
const fetchInit = {
|
||||
method: 'POST' as const,
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: options?.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text().catch(() => 'unknown error')
|
||||
throw new Error(`OpenAI API error ${response.status}: ${errorBody}`)
|
||||
}
|
||||
|
||||
return response
|
||||
const maxAttempts = isGithub ? GITHUB_429_MAX_RETRIES : 1
|
||||
let response: Response | undefined
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
response = await fetch(chatCompletionsUrl, fetchInit)
|
||||
if (response.ok) {
|
||||
return response
|
||||
}
|
||||
if (
|
||||
isGithub &&
|
||||
response.status === 429 &&
|
||||
attempt < maxAttempts - 1
|
||||
) {
|
||||
await response.text().catch(() => {})
|
||||
const delaySec = Math.min(
|
||||
GITHUB_429_BASE_DELAY_SEC * 2 ** attempt,
|
||||
GITHUB_429_MAX_DELAY_SEC,
|
||||
)
|
||||
await sleepMs(delaySec * 1000)
|
||||
continue
|
||||
}
|
||||
const errorBody = await response.text().catch(() => 'unknown error')
|
||||
const rateHint =
|
||||
isGithub && response.status === 429 ? formatRetryAfterHint(response) : ''
|
||||
throw new Error(
|
||||
`OpenAI API error ${response.status}: ${errorBody}${rateHint}`,
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error('OpenAI shim: request loop exited unexpectedly')
|
||||
}
|
||||
|
||||
private _convertNonStreamingResponse(
|
||||
@@ -773,7 +909,10 @@ class OpenAIShimMessages {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
role?: string
|
||||
content?: string | null
|
||||
content?:
|
||||
| string
|
||||
| null
|
||||
| Array<{ type?: string; text?: string }>
|
||||
tool_calls?: Array<{
|
||||
id: string
|
||||
function: { name: string; arguments: string }
|
||||
@@ -792,8 +931,25 @@ class OpenAIShimMessages {
|
||||
const choice = data.choices?.[0]
|
||||
const content: Array<Record<string, unknown>> = []
|
||||
|
||||
if (choice?.message?.content) {
|
||||
content.push({ type: 'text', text: choice.message.content })
|
||||
const rawContent = choice?.message?.content
|
||||
if (typeof rawContent === 'string' && rawContent) {
|
||||
content.push({ type: 'text', text: rawContent })
|
||||
} else if (Array.isArray(rawContent) && rawContent.length > 0) {
|
||||
const parts: string[] = []
|
||||
for (const part of rawContent) {
|
||||
if (
|
||||
part &&
|
||||
typeof part === 'object' &&
|
||||
part.type === 'text' &&
|
||||
typeof part.text === 'string'
|
||||
) {
|
||||
parts.push(part.text)
|
||||
}
|
||||
}
|
||||
const joined = parts.join('\n')
|
||||
if (joined) {
|
||||
content.push({ type: 'text', text: joined })
|
||||
}
|
||||
}
|
||||
|
||||
if (choice?.message?.tool_calls) {
|
||||
@@ -852,12 +1008,11 @@ export function createOpenAIShimClient(options: {
|
||||
maxRetries?: number
|
||||
timeout?: number
|
||||
}): unknown {
|
||||
hydrateGithubModelsTokenFromSecureStorage()
|
||||
|
||||
// When Gemini provider is active, map Gemini env vars to OpenAI-compatible ones
|
||||
// so the existing providerConfig.ts infrastructure picks them up correctly.
|
||||
if (
|
||||
process.env.CLAUDE_CODE_USE_GEMINI === '1' ||
|
||||
process.env.CLAUDE_CODE_USE_GEMINI === 'true'
|
||||
) {
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) {
|
||||
process.env.OPENAI_BASE_URL ??=
|
||||
process.env.GEMINI_BASE_URL ??
|
||||
'https://generativelanguage.googleapis.com/v1beta/openai'
|
||||
@@ -866,6 +1021,10 @@ export function createOpenAIShimClient(options: {
|
||||
if (process.env.GEMINI_MODEL && !process.env.OPENAI_MODEL) {
|
||||
process.env.OPENAI_MODEL = process.env.GEMINI_MODEL
|
||||
}
|
||||
} else if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)) {
|
||||
process.env.OPENAI_BASE_URL ??= GITHUB_MODELS_DEFAULT_BASE
|
||||
process.env.OPENAI_API_KEY ??=
|
||||
process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''
|
||||
}
|
||||
|
||||
const beta = new OpenAIShimBeta({
|
||||
|
||||
41
src/services/api/providerConfig.github.test.ts
Normal file
41
src/services/api/providerConfig.github.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { afterEach, expect, test } from 'bun:test'
|
||||
|
||||
import {
|
||||
DEFAULT_GITHUB_MODELS_API_MODEL,
|
||||
normalizeGithubModelsApiModel,
|
||||
resolveProviderRequest,
|
||||
} from './providerConfig.js'
|
||||
|
||||
const originalUseGithub = process.env.CLAUDE_CODE_USE_GITHUB
|
||||
|
||||
afterEach(() => {
|
||||
if (originalUseGithub === undefined) {
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_USE_GITHUB = originalUseGithub
|
||||
}
|
||||
})
|
||||
|
||||
test.each([
|
||||
['copilot', DEFAULT_GITHUB_MODELS_API_MODEL],
|
||||
['github:copilot', DEFAULT_GITHUB_MODELS_API_MODEL],
|
||||
['', DEFAULT_GITHUB_MODELS_API_MODEL],
|
||||
['github:gpt-4o', 'gpt-4o'],
|
||||
['gpt-4o', 'gpt-4o'],
|
||||
['github:copilot?reasoning=high', DEFAULT_GITHUB_MODELS_API_MODEL],
|
||||
] as const)('normalizeGithubModelsApiModel(%s) -> %s', (input, expected) => {
|
||||
expect(normalizeGithubModelsApiModel(input)).toBe(expected)
|
||||
})
|
||||
|
||||
test('resolveProviderRequest applies GitHub normalization when CLAUDE_CODE_USE_GITHUB=1', () => {
|
||||
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||
const r = resolveProviderRequest({ model: 'github:gpt-4o' })
|
||||
expect(r.resolvedModel).toBe('gpt-4o')
|
||||
expect(r.transport).toBe('chat_completions')
|
||||
})
|
||||
|
||||
test('resolveProviderRequest leaves model unchanged without GitHub flag', () => {
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
const r = resolveProviderRequest({ model: 'github:gpt-4o' })
|
||||
expect(r.resolvedModel).toBe('github:gpt-4o')
|
||||
})
|
||||
@@ -2,8 +2,12 @@ import { existsSync, readFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
|
||||
export const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'
|
||||
export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'
|
||||
/** Default GitHub Models API model when user selects copilot / github:copilot */
|
||||
export const DEFAULT_GITHUB_MODELS_API_MODEL = 'openai/gpt-4.1'
|
||||
|
||||
const CODEX_ALIAS_MODELS: Record<
|
||||
string,
|
||||
@@ -171,16 +175,31 @@ export function isCodexBaseUrl(baseUrl: string | undefined): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize user model string for GitHub Models inference (models.github.ai).
|
||||
* Mirrors runtime devsper `github._normalize_model_id`.
|
||||
*/
|
||||
export function normalizeGithubModelsApiModel(requestedModel: string): string {
|
||||
const noQuery = requestedModel.split('?', 1)[0] ?? requestedModel
|
||||
const segment =
|
||||
noQuery.includes(':') ? noQuery.split(':', 2)[1]!.trim() : noQuery.trim()
|
||||
if (!segment || segment.toLowerCase() === 'copilot') {
|
||||
return DEFAULT_GITHUB_MODELS_API_MODEL
|
||||
}
|
||||
return segment
|
||||
}
|
||||
|
||||
export function resolveProviderRequest(options?: {
|
||||
model?: string
|
||||
baseUrl?: string
|
||||
fallbackModel?: string
|
||||
}): ResolvedProviderRequest {
|
||||
const isGithubMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||
const requestedModel =
|
||||
options?.model?.trim() ||
|
||||
process.env.OPENAI_MODEL?.trim() ||
|
||||
options?.fallbackModel?.trim() ||
|
||||
'gpt-4o'
|
||||
(isGithubMode ? 'github:copilot' : 'gpt-4o')
|
||||
const descriptor = parseModelDescriptor(requestedModel)
|
||||
const rawBaseUrl =
|
||||
options?.baseUrl ??
|
||||
@@ -192,10 +211,16 @@ export function resolveProviderRequest(options?: {
|
||||
? 'codex_responses'
|
||||
: 'chat_completions'
|
||||
|
||||
const resolvedModel =
|
||||
transport === 'chat_completions' &&
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||
? normalizeGithubModelsApiModel(requestedModel)
|
||||
: descriptor.baseModel
|
||||
|
||||
return {
|
||||
transport,
|
||||
requestedModel,
|
||||
resolvedModel: descriptor.baseModel,
|
||||
resolvedModel,
|
||||
baseUrl:
|
||||
(rawBaseUrl ??
|
||||
(transport === 'codex_responses'
|
||||
|
||||
Reference in New Issue
Block a user