Merge branch 'Gitlawb:main' into development
This commit is contained in:
@@ -22,7 +22,7 @@ function buildPrimarySection(): Property[] {
|
|||||||
const nameValue = customTitle ?? <Text dimColor>/rename to add a name</Text>;
|
const nameValue = customTitle ?? <Text dimColor>/rename to add a name</Text>;
|
||||||
return [{
|
return [{
|
||||||
label: 'Version',
|
label: 'Version',
|
||||||
value: MACRO.VERSION
|
value: MACRO.DISPLAY_VERSION ?? MACRO.VERSION
|
||||||
}, {
|
}, {
|
||||||
label: 'Session name',
|
label: 'Session name',
|
||||||
value: nameValue
|
value: nameValue
|
||||||
|
|||||||
121
src/services/api/client.test.ts
Normal file
121
src/services/api/client.test.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||||
|
import { getAnthropicClient } from './client.js'
|
||||||
|
|
||||||
|
type FetchType = typeof globalThis.fetch
|
||||||
|
|
||||||
|
type ShimClient = {
|
||||||
|
beta: {
|
||||||
|
messages: {
|
||||||
|
create: (params: Record<string, unknown>) => Promise<unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch
|
||||||
|
const originalMacro = (globalThis as Record<string, unknown>).MACRO
|
||||||
|
const originalEnv = {
|
||||||
|
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
|
||||||
|
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
|
||||||
|
GEMINI_MODEL: process.env.GEMINI_MODEL,
|
||||||
|
GEMINI_BASE_URL: process.env.GEMINI_BASE_URL,
|
||||||
|
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
|
||||||
|
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||||
|
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
|
||||||
|
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||||
|
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
||||||
|
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
;(globalThis as Record<string, unknown>).MACRO = { VERSION: 'test-version' }
|
||||||
|
process.env.CLAUDE_CODE_USE_GEMINI = '1'
|
||||||
|
process.env.GEMINI_API_KEY = 'gemini-test-key'
|
||||||
|
process.env.GEMINI_MODEL = 'gemini-2.0-flash'
|
||||||
|
process.env.GEMINI_BASE_URL = 'https://gemini.example/v1beta/openai'
|
||||||
|
|
||||||
|
delete process.env.GOOGLE_API_KEY
|
||||||
|
delete process.env.OPENAI_API_KEY
|
||||||
|
delete process.env.OPENAI_BASE_URL
|
||||||
|
delete process.env.OPENAI_MODEL
|
||||||
|
delete process.env.ANTHROPIC_API_KEY
|
||||||
|
delete process.env.ANTHROPIC_AUTH_TOKEN
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
;(globalThis as Record<string, unknown>).MACRO = originalMacro
|
||||||
|
process.env.CLAUDE_CODE_USE_GEMINI = originalEnv.CLAUDE_CODE_USE_GEMINI
|
||||||
|
process.env.GEMINI_API_KEY = originalEnv.GEMINI_API_KEY
|
||||||
|
process.env.GEMINI_MODEL = originalEnv.GEMINI_MODEL
|
||||||
|
process.env.GEMINI_BASE_URL = originalEnv.GEMINI_BASE_URL
|
||||||
|
process.env.GOOGLE_API_KEY = originalEnv.GOOGLE_API_KEY
|
||||||
|
process.env.OPENAI_API_KEY = originalEnv.OPENAI_API_KEY
|
||||||
|
process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL
|
||||||
|
process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL
|
||||||
|
process.env.ANTHROPIC_API_KEY = originalEnv.ANTHROPIC_API_KEY
|
||||||
|
process.env.ANTHROPIC_AUTH_TOKEN = originalEnv.ANTHROPIC_AUTH_TOKEN
|
||||||
|
globalThis.fetch = originalFetch
|
||||||
|
})
|
||||||
|
|
||||||
|
test('routes Gemini provider requests through the OpenAI-compatible shim', async () => {
|
||||||
|
let capturedUrl: string | undefined
|
||||||
|
let capturedHeaders: Headers | undefined
|
||||||
|
let capturedBody: Record<string, unknown> | undefined
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input, init) => {
|
||||||
|
capturedUrl =
|
||||||
|
typeof input === 'string'
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: input.url
|
||||||
|
capturedHeaders = new Headers(init?.headers)
|
||||||
|
capturedBody = JSON.parse(String(init?.body)) as Record<string, unknown>
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
id: 'chatcmpl-gemini',
|
||||||
|
model: 'gemini-2.0-flash',
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'gemini ok',
|
||||||
|
},
|
||||||
|
finish_reason: 'stop',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: 8,
|
||||||
|
completion_tokens: 3,
|
||||||
|
total_tokens: 11,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}) as FetchType
|
||||||
|
|
||||||
|
const client = (await getAnthropicClient({
|
||||||
|
maxRetries: 0,
|
||||||
|
model: 'gemini-2.0-flash',
|
||||||
|
})) as unknown as ShimClient
|
||||||
|
|
||||||
|
const response = await client.beta.messages.create({
|
||||||
|
model: 'gemini-2.0-flash',
|
||||||
|
system: 'test system',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
max_tokens: 64,
|
||||||
|
stream: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(capturedUrl).toBe('https://gemini.example/v1beta/openai/chat/completions')
|
||||||
|
expect(capturedHeaders?.get('authorization')).toBe('Bearer gemini-test-key')
|
||||||
|
expect(capturedBody?.model).toBe('gemini-2.0-flash')
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
role: 'assistant',
|
||||||
|
model: 'gemini-2.0-flash',
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -156,7 +156,8 @@ export async function getAnthropicClient({
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
|
||||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) ||
|
||||||
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
|
||||||
) {
|
) {
|
||||||
const { createOpenAIShimClient } = await import('./openaiShim.js')
|
const { createOpenAIShimClient } = await import('./openaiShim.js')
|
||||||
return createOpenAIShimClient({
|
return createOpenAIShimClient({
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ function makeUsage(usage?: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeMessageId(): string {
|
function makeMessageId(): string {
|
||||||
return `msg_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`
|
return `msg_${crypto.randomUUID().replace(/-/g, '')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeToolUseId(toolUseId: string | undefined): {
|
function normalizeToolUseId(toolUseId: string | undefined): {
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ function convertMessages(
|
|||||||
input?: unknown
|
input?: unknown
|
||||||
extra_content?: Record<string, unknown>
|
extra_content?: Record<string, unknown>
|
||||||
}) => ({
|
}) => ({
|
||||||
id: tu.id ?? `call_${Math.random().toString(36).slice(2)}`,
|
id: tu.id ?? `call_${crypto.randomUUID().replace(/-/g, '')}`,
|
||||||
type: 'function' as const,
|
type: 'function' as const,
|
||||||
function: {
|
function: {
|
||||||
name: tu.name ?? 'unknown',
|
name: tu.name ?? 'unknown',
|
||||||
@@ -389,7 +389,7 @@ interface OpenAIStreamChunk {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeMessageId(): string {
|
function makeMessageId(): string {
|
||||||
return `msg_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`
|
return `msg_${crypto.randomUUID().replace(/-/g, '')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertChunkUsage(
|
function convertChunkUsage(
|
||||||
@@ -610,6 +610,23 @@ async function* openaiStreamToAnthropic(
|
|||||||
: choice.finish_reason === 'length'
|
: choice.finish_reason === 'length'
|
||||||
? 'max_tokens'
|
? 'max_tokens'
|
||||||
: 'end_turn'
|
: 'end_turn'
|
||||||
|
if (choice.finish_reason === 'content_filter' || choice.finish_reason === 'safety') {
|
||||||
|
// Gemini/Azure content safety filter blocked the response.
|
||||||
|
// Emit a visible text block so the user knows why output was truncated.
|
||||||
|
if (!hasEmittedContentStart) {
|
||||||
|
yield {
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: contentBlockIndex,
|
||||||
|
content_block: { type: 'text', text: '' },
|
||||||
|
}
|
||||||
|
hasEmittedContentStart = true
|
||||||
|
}
|
||||||
|
yield {
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: contentBlockIndex,
|
||||||
|
delta: { type: 'text_delta', text: '\n\n[Content blocked by provider safety filter]' },
|
||||||
|
}
|
||||||
|
}
|
||||||
lastStopReason = stopReason
|
lastStopReason = stopReason
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
@@ -841,7 +858,14 @@ class OpenAIShimMessages {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = process.env.OPENAI_API_KEY ?? ''
|
const apiKey = process.env.OPENAI_API_KEY ?? ''
|
||||||
const isAzure = /cognitiveservices\.azure\.com|openai\.azure\.com/.test(request.baseUrl)
|
// Detect Azure endpoints by hostname (not raw URL) to prevent bypass via
|
||||||
|
// path segments like https://evil.com/cognitiveservices.azure.com/
|
||||||
|
let isAzure = false
|
||||||
|
try {
|
||||||
|
const { hostname } = new URL(request.baseUrl)
|
||||||
|
isAzure = hostname.endsWith('.azure.com') &&
|
||||||
|
(hostname.includes('cognitiveservices') || hostname.includes('openai') || hostname.includes('services.ai'))
|
||||||
|
} catch { /* malformed URL — not Azure */ }
|
||||||
|
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
if (isAzure) {
|
if (isAzure) {
|
||||||
@@ -1003,6 +1027,13 @@ class OpenAIShimMessages {
|
|||||||
? 'max_tokens'
|
? 'max_tokens'
|
||||||
: 'end_turn'
|
: 'end_turn'
|
||||||
|
|
||||||
|
if (choice?.finish_reason === 'content_filter' || choice?.finish_reason === 'safety') {
|
||||||
|
content.push({
|
||||||
|
type: 'text',
|
||||||
|
text: '\n\n[Content blocked by provider safety filter]',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: data.id ?? makeMessageId(),
|
id: data.id ?? makeMessageId(),
|
||||||
type: 'message',
|
type: 'message',
|
||||||
|
|||||||
@@ -50,9 +50,11 @@ const OPENAI_CONTEXT_WINDOWS: Record<string, number> = {
|
|||||||
'gemini-2.5-flash': 1_048_576,
|
'gemini-2.5-flash': 1_048_576,
|
||||||
|
|
||||||
// Ollama local models
|
// Ollama local models
|
||||||
'llama3.3:70b': 8_192,
|
// Llama 3.1+ models support 128k context natively (Meta official specs).
|
||||||
'llama3.1:8b': 8_192,
|
// Ollama defaults to num_ctx=8192 but users can configure higher values.
|
||||||
'llama3.2:3b': 8_192,
|
'llama3.3:70b': 128_000,
|
||||||
|
'llama3.1:8b': 128_000,
|
||||||
|
'llama3.2:3b': 128_000,
|
||||||
'qwen2.5-coder:32b': 32_768,
|
'qwen2.5-coder:32b': 32_768,
|
||||||
'qwen2.5-coder:7b': 32_768,
|
'qwen2.5-coder:7b': 32_768,
|
||||||
'deepseek-coder-v2:16b': 163_840,
|
'deepseek-coder-v2:16b': 163_840,
|
||||||
@@ -122,7 +124,11 @@ const OPENAI_MAX_OUTPUT_TOKENS: Record<string, number> = {
|
|||||||
|
|
||||||
function lookupByModel<T>(table: Record<string, T>, model: string): T | undefined {
|
function lookupByModel<T>(table: Record<string, T>, model: string): T | undefined {
|
||||||
if (table[model] !== undefined) return table[model]
|
if (table[model] !== undefined) return table[model]
|
||||||
for (const key of Object.keys(table)) {
|
// Sort keys by length descending so the most specific prefix wins.
|
||||||
|
// Without this, 'gpt-4-turbo-preview' could match 'gpt-4' (8k) instead
|
||||||
|
// of 'gpt-4-turbo' (128k) depending on V8's key iteration order.
|
||||||
|
const sortedKeys = Object.keys(table).sort((a, b) => b.length - a.length)
|
||||||
|
for (const key of sortedKeys) {
|
||||||
if (model.startsWith(key)) return table[key]
|
if (model.startsWith(key)) return table[key]
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
|
|||||||
Reference in New Issue
Block a user