mirror of
https://github.com/Twigpine/openclaude.git
synced 2026-11-10 05:01:49 -10:00
test(opengateway): pin failed-refresh model list behaviour; fix web naming
Address CodeRabbit re-review on #2488: - web env var descriptions use "Twigpine OpenGateway" consistently, and OPENGATEWAY_BASE_URL names its https://api.twigpine.com/v1 default. - The cache wording in advanced-setup and README now matches the code: a failed startup refresh keeps the last cached list, while a manual `r` refresh clears the cache first, so a failed one leaves only the offline floor. - discoveryService: a failed OpenGateway refresh keeps the floor when nothing was fetched and keeps the last cached live list after an earlier success. - /model: after a failed refresh the picker shows the cached list plus the floor, the "Showing cached ... Last refresh failed" warning, and the refresh action. mockDescriptorDiscovery gains an optional cachedError. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
12bbf2357d
commit
673505533c
5 files changed
+127
-7
No files matched your search
@@ -443,7 +443,7 @@ OpenClaude supports multiple providers, but behavior is not identical across all
|
||||
- Smaller local models can struggle with long multi-step tool flows
|
||||
- Some providers impose lower output caps than the CLI defaults, and OpenClaude adapts where possible
|
||||
- AI/ML API uses the OpenAI-compatible route, defaults to `gpt-4o`, and only surfaces chat-capable models from its public catalog
|
||||
- Twigpine OpenGateway is the fresh-install startup default and requires an API key from https://twigpine.com/console/gateway/keys. It uses one OpenAI-compatible base URL (`https://api.twigpine.com/v1`; the older `opengateway.gitlawb.com` address still works). `/model` lists the gateway's active models from a cached copy of its live list (refreshed at startup once a day, or on demand with `r` in `/model`; a failed refresh keeps the last cached list), plus `auto`, `mimo-v2.6-pro`, and `mimo-v2.6-flash`, which are always listed. Do not pin the base URL to `/v1/xiaomi-mimo`.
|
||||
- Twigpine OpenGateway is the fresh-install startup default and requires an API key from https://twigpine.com/console/gateway/keys. It uses one OpenAI-compatible base URL (`https://api.twigpine.com/v1`; the older `opengateway.gitlawb.com` address still works). `/model` lists the gateway's active models from a cached copy of its live list (refreshed at startup once a day, or on demand with `r` in `/model`; a failed startup refresh keeps the last cached list), plus `auto`, `mimo-v2.6-pro`, and `mimo-v2.6-flash`, which are always listed. Do not pin the base URL to `/v1/xiaomi-mimo`.
|
||||
- Z.AI GLM Coding Plan uses `https://api.z.ai/api/coding/paas/v4` with `glm-5.2` by default. On that direct route, GLM-5.3-Flash is selectable as `glm-5.3-flash` and accepts image input. Its OpenClaude effort choices are `low`, `high`, and `xhigh`, with `xhigh` requesting Z.AI `max`; GLM-5.3 and the existing GLM-5.2 query controls remain supported. These capabilities are not claimed for gateways that happen to carry the same model name.
|
||||
- Xiaomi MiMo uses `api-key` header auth on the direct OpenAI-compatible route and currently does not support `/usage` reporting in OpenClaude
|
||||
- GitHub Copilot serializes sub-agent execution by default to reduce Premium Request consumption — see [Agent Routing and Step Limits](docs/agent-routing.md#github-copilot-sub-agent-optimization) for tuning
|
||||
|
||||
@@ -332,9 +332,11 @@ and switch models with `/model` or `OPENAI_MODEL`. The older
|
||||
`/model` shows the gateway's list of active models from the public
|
||||
`GET https://api.twigpine.com/v1/models` endpoint (no key needed). OpenClaude
|
||||
caches that list for a day and fetches it again at startup once the cache has
|
||||
expired; press `r` in `/model` to refresh it on demand. If a refresh fails, the
|
||||
picker keeps showing the last cached list, so a model the gateway has retired
|
||||
since then can stay listed until a refresh succeeds. `auto` (smart routing),
|
||||
expired; press `r` in `/model` to refresh it on demand. If the startup refresh
|
||||
fails, the picker keeps showing the last cached list, so a model the gateway has
|
||||
retired since then can stay listed until a refresh succeeds. A manual refresh
|
||||
discards the cached list first, so if it fails only the three models below
|
||||
remain until the next successful refresh. `auto` (smart routing),
|
||||
`mimo-v2.6-pro`, and `mimo-v2.6-flash` are always listed, including when the
|
||||
list has never been fetched.
|
||||
|
||||
|
||||
@@ -347,6 +347,8 @@ type DiscoveryModelForTest = {
|
||||
|
||||
function mockDescriptorDiscovery(options: {
|
||||
cachedModels: DiscoveryModelForTest[] | null
|
||||
/** Error recorded on the cache entry by the last failed refresh. */
|
||||
cachedError?: string
|
||||
error?: string
|
||||
discoveredModels?: DiscoveryModelForTest[]
|
||||
routeId?: string
|
||||
@@ -356,7 +358,9 @@ function mockDescriptorDiscovery(options: {
|
||||
getCachedModels: mock(async () => options.cachedModels === null ? null : ({
|
||||
models: options.cachedModels,
|
||||
updatedAt: Date.now(),
|
||||
error: null,
|
||||
error: options.cachedError
|
||||
? { message: options.cachedError, recordedAt: Date.now() }
|
||||
: null,
|
||||
})),
|
||||
isCacheStale: mock(async () => false),
|
||||
parseDurationString: (value: number | string) =>
|
||||
@@ -1846,6 +1850,64 @@ test('/model merges non-empty OpenGateway discovery cache with the offline floor
|
||||
}
|
||||
})
|
||||
|
||||
test('/model keeps the cached OpenGateway list and the offline floor after a failed refresh', async () => {
|
||||
const activeProfile = {
|
||||
id: 'opengateway-profile',
|
||||
name: 'Twigpine OpenGateway',
|
||||
provider: 'gitlawb-opengateway',
|
||||
baseUrl: 'https://api.twigpine.com/v1',
|
||||
model: 'mimo-v2.6-pro',
|
||||
apiKey: 'sk-opengateway',
|
||||
}
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
process.env.OPENAI_BASE_URL = activeProfile.baseUrl
|
||||
process.env.OPENAI_API_KEY = activeProfile.apiKey
|
||||
process.env.OPENAI_MODEL = activeProfile.model
|
||||
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = '1'
|
||||
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID = activeProfile.id
|
||||
delete process.env.OPENROUTER_API_KEY
|
||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.CLAUDE_CODE_USE_MISTRAL
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
||||
delete process.env.CLAUDE_CODE_USE_VERTEX
|
||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY
|
||||
delete process.env.OPENAI_API_BASE
|
||||
|
||||
mockProviderProfiles({
|
||||
getActiveProviderProfile: () => activeProfile,
|
||||
})
|
||||
// The startup refresh failed after an earlier success: the cache entry
|
||||
// still holds that live list and records the error.
|
||||
mockDescriptorDiscovery({
|
||||
cachedModels: [
|
||||
{ id: 'moonshotai/kimi-k3', apiName: 'moonshotai/kimi-k3', label: 'Kimi K3' },
|
||||
],
|
||||
cachedError: 'HTTP 503',
|
||||
routeId: 'gitlawb-opengateway',
|
||||
})
|
||||
|
||||
const rendered = await renderModelCommandWithCapturedPicker(
|
||||
'opengateway-picker-cached-after-failed-refresh',
|
||||
)
|
||||
try {
|
||||
const props = rendered.getCapturedProps()
|
||||
expect(
|
||||
(props.optionsOverride as ModelOption[]).map(option => option.value),
|
||||
).toEqual(['auto', 'mimo-v2.6-pro', 'mimo-v2.6-flash', 'moonshotai/kimi-k3'])
|
||||
expect(props.discoveryState).toEqual({
|
||||
message:
|
||||
'Showing cached Twigpine OpenGateway models. Last refresh failed: HTTP 503',
|
||||
tone: 'warning',
|
||||
})
|
||||
// The documented `r` refresh stays available.
|
||||
expect(props.onRefresh).toEqual(expect.any(Function))
|
||||
} finally {
|
||||
rendered.instance.unmount()
|
||||
rendered.stdout.end()
|
||||
}
|
||||
})
|
||||
|
||||
test('/model OpenGateway interactive refresh preserves availability filter and hides expired models', async () => {
|
||||
const activeProfile = {
|
||||
id: 'opengateway-profile',
|
||||
|
||||
@@ -764,6 +764,62 @@ describe('discoverModelsForRoute', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('opengateway keeps the offline floor and the last cached live list when a refresh fails', async () => {
|
||||
const { discoverModelsForRoute } = await loadDiscoveryServiceModule()
|
||||
const unavailable = mock(() =>
|
||||
Promise.resolve(new Response('unavailable', { status: 503 })),
|
||||
) as unknown as typeof globalThis.fetch
|
||||
const apiNamesOf = (result: { models: Array<{ apiName: string }> } | null) =>
|
||||
result?.models.map(model => model.apiName)
|
||||
|
||||
// Never fetched: a failed refresh still leaves the offline floor.
|
||||
setMockFetch(unavailable)
|
||||
const neverFetched = await discoverModelsForRoute('gitlawb-opengateway', {
|
||||
forceRefresh: true,
|
||||
})
|
||||
expect(neverFetched?.error).not.toBeNull()
|
||||
expect(apiNamesOf(neverFetched)).toEqual([
|
||||
'auto',
|
||||
'mimo-v2.6-pro',
|
||||
'mimo-v2.6-flash',
|
||||
])
|
||||
|
||||
// A successful refresh caches the live list.
|
||||
setMockFetch(mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{ id: 'xiaomi/mimo-v2.6-pro', status: 'active' },
|
||||
{ id: 'moonshotai/kimi-k3', name: 'Kimi K3', status: 'active' },
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
),
|
||||
) as unknown as typeof globalThis.fetch)
|
||||
const fresh = await discoverModelsForRoute('gitlawb-opengateway', {
|
||||
forceRefresh: true,
|
||||
})
|
||||
expect(fresh?.source).toBe('network')
|
||||
|
||||
// The next (startup-style, cache-preserving) refresh fails, so the last
|
||||
// cached list is kept on top of the floor: a model retired since then
|
||||
// stays listed until a refresh succeeds.
|
||||
setMockFetch(unavailable)
|
||||
const afterFailure = await discoverModelsForRoute('gitlawb-opengateway', {
|
||||
forceRefresh: true,
|
||||
})
|
||||
expect(afterFailure?.source).toBe('stale-cache')
|
||||
expect(afterFailure?.error).not.toBeNull()
|
||||
expect(apiNamesOf(afterFailure)).toEqual([
|
||||
'auto',
|
||||
'mimo-v2.6-pro',
|
||||
'mimo-v2.6-flash',
|
||||
'moonshotai/kimi-k3',
|
||||
])
|
||||
})
|
||||
|
||||
test('openai-compatible discovery applies descriptor static headers with auth', async () => {
|
||||
const { discoverModelsForRoute } = await loadDiscoveryServiceModule()
|
||||
|
||||
|
||||
@@ -68,12 +68,12 @@ export const envVars: EnvVar[] = [
|
||||
{ name: 'ANTHROPIC_API_KEY', description: 'Anthropic API key (also the strict auth path in --bare mode).' },
|
||||
{ name: 'ANTHROPIC_AUTH_TOKEN', description: 'Bearer token alternative to an Anthropic API key.' },
|
||||
{ name: 'ANTHROPIC_FIRST_PARTY_PROXY_HOSTS', description: 'Opt-in comma-separated list of loopback host:port entries that keep subscription (OAuth) auth when ANTHROPIC_BASE_URL points at a local transparent proxy.' },
|
||||
{ name: 'OPENAI_API_KEY', description: 'Key for OpenAI-compatible providers and gateways (incl. Opengateway).' },
|
||||
{ name: 'OPENAI_API_KEY', description: 'Key for OpenAI-compatible providers and gateways (incl. Twigpine OpenGateway).' },
|
||||
{ name: 'OPENAI_API_KEYS', description: 'Comma-separated key pool for OpenAI-compatible endpoints; rotates on failure and is checked before OPENAI_API_KEY.' },
|
||||
{ name: 'OPENAI_BASE_URL', description: 'Base URL of an OpenAI-compatible /v1 endpoint (OpenRouter, LM Studio, LiteLLM, …).' },
|
||||
{ name: 'OPENAI_MODEL', description: 'Model name to request from the OpenAI-compatible endpoint.' },
|
||||
{ name: 'OPENGATEWAY_API_KEY', description: 'Twigpine OpenGateway key (preferred over OPENAI_API_KEY for the gateway).' },
|
||||
{ name: 'OPENGATEWAY_BASE_URL', description: 'Override the Opengateway base URL.' },
|
||||
{ name: 'OPENGATEWAY_BASE_URL', description: 'Override the Twigpine OpenGateway base URL (default https://api.twigpine.com/v1).' },
|
||||
{ name: 'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS', description: 'JSON map of model → context window; takes precedence over settings.modelLimits.' },
|
||||
{ name: 'CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS', description: 'JSON map of model → max output tokens; takes precedence over settings.modelLimits.' },
|
||||
{ name: 'GEMINI_API_KEY', description: 'Gemini API key (the preset reads this, not GOOGLE_API_KEY).' },
|
||||
|
||||
Reference in new issue
Block a user