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:
Kevin CodexandClaude Opus 5.5 committed 2026-11-09 12:38:18 +08:00
1 parent 12bbf2357d
commit 673505533c
5 files changed
+127 -7

No files matched your search

+1 -1
View File
@@ -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
+5 -3
View File
@@ -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.
+63 -1
View File
@@ -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',
+56
View File
@@ -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()
+2 -2
View File
@@ -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).' },