Make Z.ai provider base URL user-overridable

Z.ai keys on the coding subscription get 429 'Insufficient balance' on the standard /api/paas/v4 endpoint. Add a per-provider BaseURLField to the Z.ai registry entry, a ZaiBaseURL override on AIConfig (returned by GetBaseURLForProvider when set, else the standard default), the handler request/response/apply plumbing, and a 'Custom Base URL' field on the Z.ai provider card so operators can point at /api/coding/paas/v4. Mirrors the existing OpenAI custom-base-URL override; the standard endpoint remains the default for pay-as-you-go users. Adds config/handler/frontend proofs and updates the ai-runtime, api-contracts, frontend-primitives, agent-lifecycle, and storage-recovery contracts.
This commit is contained in:
rcourtman 2026-06-23 22:49:55 +01:00
parent 9346d2f99d
commit 1f6fe16e49
15 changed files with 61 additions and 10 deletions

View file

@ -113,7 +113,10 @@ that case means governed actions have a node-agent path; it must not be
presented as proof that a guest-local agent is installed.
Agent-facing operations-loop status wiring in `internal/api/router.go` and
`internal/api/agent_resource_context.go` is lifecycle-adjacent only because it
shares agent route infrastructure. Workflow starter counts on that endpoint,
shares agent route infrastructure. Other handlers in `internal/api/` such as
the AI settings handler (`ai_handlers.go`) carry AI provider configuration
(for example per-provider base URL overrides) that is ai-runtime config-surface
and is not agent enrollment, liveness, or lifecycle state. Workflow starter counts on that endpoint,
contextual Assistant/external-agent collaboration counts inside the Assistant
step, the content-free Patrol control starter split, and Patrol control
completed-loop, resolved-loop, or `patrolControlValueState` proof mirrored to

View file

@ -114,6 +114,10 @@ factory branches, settings-card literals, or per-provider model-list code. New
direct chat-compatible providers extend that registry and the shared
chat-compatible provider client; native transports remain explicit only where
the protocol is not chat-compatible.
Provider definitions that declare a BaseURLField (today OpenAI, Ollama, and
Z.ai) expose a user-overridable endpoint via the AI settings payload; the
registry default base URL applies when no override is stored, so one provider
can serve both standard and alternate (e.g. Z.ai coding) endpoint tiers.
## Canonical Files

View file

@ -2401,7 +2401,7 @@ payload shape change when the portal presents compact client rows.
20. Backend config/settings handlers pointing operator guidance at GitHub `main` docs when the running build already ships that guidance locally under `/docs/`
21. Telemetry preview or reset endpoints drifting from the exact server-owned telemetry runtime contract instead of reusing the same source-of-truth snapshot and install-ID state the background sender uses
22. Shared SSO test or metadata-preview handlers open-coding outbound metadata/discovery URLs, allowing userinfo-bearing HTTP(S) inputs, or rebuilding `/.well-known/openid-configuration` with origin-root string concatenation instead of the shared validated URL helpers before any outbound request
23. AI settings handlers echoing raw provider secrets or testing the wrong provider model: `/api/settings/ai` may expose masked provider-auth presence such as `ollama_password_set`, but backend payloads must never echo stored secrets back to clients, and provider-specific test routes must stay bound to the selected provider's own configured model instead of whichever other provider currently owns the default `model` field
23. AI settings handlers echoing raw provider secrets or testing the wrong provider model: `/api/settings/ai` may expose masked provider-auth presence such as `ollama_password_set`, but backend payloads must never echo stored secrets back to clients, and provider-specific test routes must stay bound to the selected provider's own configured model instead of whichever other provider currently owns the default `model` field; per-provider base URL overrides (`openai_base_url`, `zai_base_url`, and Ollama's `ollama_base_url`) round-trip through the same settings payload and take precedence over the registry default, so a provider can be retargeted (e.g. Z.ai standard versus coding endpoint) without code changes
24. `/api/diagnostics` exposing maintainer/admin analytics such as commercial
funnel, sales funnel, pricing/checkout conversion, or infrastructure
onboarding telemetry. Customer diagnostics may expose runtime health,

View file

@ -2194,7 +2194,8 @@ the shared primitive rather than a local bordered button group.
AI settings provider fields are a governed frontend primitive, not a
provider-local form fork. The shared provider configuration section must render
provider-specific controls from `aiSettingsModel.ts` `extraFields`, including
Ollama `keep_alive`, so Assistant and Patrol keep one settings shape across
Ollama `keep_alive` and the Z.ai custom base URL override, so Assistant and
Patrol keep one settings shape across
labeling, help affordances, helper copy, and persistence binding.
The shared AI model picker owns model route search and presentation for
Assistant surfaces. External open requests may seed an initial search query,

View file

@ -78,7 +78,10 @@ remain governed by the setup-script and source-specific backup API boundaries
below.
Operations-loop status wiring in `internal/api/agent_resource_context.go` is
storage/recovery-adjacent only through the shared action-audit and verification
projection. Starter counts, contextual Assistant/external-agent collaboration
projection. Sibling handlers in `internal/api/` such as the AI settings handler
(`ai_handlers.go`) carry AI provider configuration (for example per-provider
base URL overrides) that is ai-runtime config-surface, not storage or recovery
state. Starter counts, contextual Assistant/external-agent collaboration
counts, Patrol control completed-loop or resolved-loop outcome evidence,
`patrolControlValueState`, legacy `patrolAutonomy*` compatibility aliases, and
the operator-readable `progressLabel` on that

View file

@ -33,10 +33,10 @@ describe('AIAPI', () => {
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/settings/ai');
apiFetchJSONMock.mockResolvedValueOnce({ configured: true } as any);
await AIAPI.updateSettings({ enabled: true });
await AIAPI.updateSettings({ enabled: true, zai_base_url: 'https://api.z.ai/api/coding/paas/v4' });
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/settings/ai/update', {
method: 'PUT',
body: JSON.stringify({ enabled: true }),
body: JSON.stringify({ enabled: true, zai_base_url: 'https://api.z.ai/api/coding/paas/v4' }),
});
apiFetchJSONMock.mockResolvedValueOnce({ models: [] } as any);

View file

@ -673,6 +673,7 @@ describe('settings architecture guardrails', () => {
expect(aiSettingsModelSource).toContain('extraFields: [');
expect(aiSettingsModelSource).toContain("inputField: 'ollamaKeepAlive'");
expect(aiSettingsModelSource).toContain("helpContentId: 'ai.ollama.keepAlive'");
expect(aiSettingsModelSource).toContain("inputField: 'zaiBaseUrl'");
expect(aiProviderConfigurationSectionSource).toContain('<For each={config.extraFields || []}>');
expect(aiProviderConfigurationSectionSource).toContain(
'aria-label={`${getAIProviderDisplayName(config.provider)} ${extraField.label}`}',

View file

@ -23,6 +23,7 @@ export type AIProviderCredentialsFormState = {
ollamaBaseUrl: string;
ollamaKeepAlive: string;
openaiBaseUrl: string;
zaiBaseUrl: string;
};
export type AIProviderConfig = {
@ -162,6 +163,15 @@ export const AI_PROVIDER_CONFIGS: AIProviderConfig[] = [
actionLinkLabel: 'Open docs →',
actionLinkHref: 'https://docs.z.ai/guides/develop/openai/python',
helperText: 'Uses https://api.z.ai/api/paas/v4 automatically.',
extraFields: [
{
label: 'Custom Base URL',
inputField: 'zaiBaseUrl',
placeholder: 'https://api.z.ai/api/coding/paas/v4 (coding plan)',
type: 'url',
helperText: 'Override for a z.ai coding subscription.',
},
],
clearTitle: 'Clear API key',
},
{

View file

@ -54,7 +54,7 @@ const AI_SETTINGS_PROVIDER_PAYLOAD_FIELDS: Record<AIProvider, string[]> = {
openai: ['openai_api_key', 'openai_base_url'],
openrouter: ['openrouter_api_key'],
deepseek: ['deepseek_api_key'],
zai: ['zai_api_key'],
zai: ['zai_api_key', 'zai_base_url'],
groq: ['groq_api_key'],
mistral: ['mistral_api_key'],
cerebras: ['cerebras_api_key'],
@ -322,6 +322,7 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
ollamaBaseUrl: 'http://localhost:11434',
ollamaKeepAlive: '30s',
openaiBaseUrl: '',
zaiBaseUrl: '',
costBudgetUSD30d: '',
requestTimeoutSeconds: 300,
controlLevel: 'read_only' as AIControlLevel,
@ -413,6 +414,7 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
ollamaBaseUrl: 'http://localhost:11434',
ollamaKeepAlive: '30s',
openaiBaseUrl: '',
zaiBaseUrl: '',
costBudgetUSD30d: '',
requestTimeoutSeconds: 300,
controlLevel: 'read_only',
@ -455,6 +457,7 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
ollamaBaseUrl: data.ollama_base_url || 'http://localhost:11434',
ollamaKeepAlive: data.ollama_keep_alive ?? '30s',
openaiBaseUrl: data.openai_base_url || '',
zaiBaseUrl: data.zai_base_url || '',
costBudgetUSD30d:
typeof data.cost_budget_usd_30d === 'number' && data.cost_budget_usd_30d > 0
? String(data.cost_budget_usd_30d)
@ -980,6 +983,9 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
if (form.openaiBaseUrl !== (settings()?.openai_base_url || '')) {
payload.openai_base_url = form.openaiBaseUrl.trim();
}
if (form.zaiBaseUrl !== (settings()?.zai_base_url || '')) {
payload.zai_base_url = form.zaiBaseUrl.trim();
}
const rawBudget = form.costBudgetUSD30d.trim();
const parsedBudget = rawBudget === '' ? 0 : Number(rawBudget);

View file

@ -104,6 +104,7 @@ export interface AISettings {
ollama_base_url: string; // Ollama server URL
ollama_keep_alive: string; // Ollama keep_alive value; empty uses the server default
openai_base_url?: string; // Custom OpenAI base URL
zai_base_url?: string; // Custom Z.ai base URL (e.g. coding endpoint)
configured_providers: AIProvider[]; // List of providers with credentials
providers?: AIProviderDefinition[]; // Server-authored provider registry metadata
@ -179,6 +180,7 @@ export interface AISettingsUpdateRequest {
ollama_base_url?: string; // Set Ollama server URL
ollama_keep_alive?: string; // Set Ollama keep_alive; empty uses the server default
openai_base_url?: string; // Set custom OpenAI base URL
zai_base_url?: string; // Set custom Z.ai base URL (e.g. coding endpoint)
// Clear flags for removing credentials
clear_anthropic_key?: boolean; // Clear Anthropic API key
clear_openai_key?: boolean; // Clear OpenAI API key

View file

@ -1685,6 +1685,7 @@ func shouldRestartAIChat(req AISettingsUpdateRequest) bool {
req.OllamaPassword != nil ||
req.OllamaKeepAlive != nil ||
req.OpenAIBaseURL != nil ||
req.ZaiBaseURL != nil ||
req.ClearAnthropicKey != nil ||
req.ClearOpenAIKey != nil ||
req.ClearOpenRouterKey != nil ||
@ -1761,6 +1762,7 @@ func aiSettingsUpdateTouchesPatrolReadiness(req AISettingsUpdateRequest) bool {
req.OllamaPassword != nil ||
req.OllamaKeepAlive != nil ||
req.OpenAIBaseURL != nil ||
req.ZaiBaseURL != nil ||
req.ClearAnthropicKey != nil ||
req.ClearOpenAIKey != nil ||
req.ClearOpenRouterKey != nil ||
@ -2320,6 +2322,7 @@ type AISettingsResponse struct {
OllamaPasswordSet bool `json:"ollama_password_set"` // true if an Ollama password is stored
OllamaKeepAlive string `json:"ollama_keep_alive"` // Ollama keep_alive value; empty uses server default
OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI base URL
ZaiBaseURL string `json:"zai_base_url,omitempty"` // Custom Z.ai base URL (e.g. coding endpoint)
ConfiguredProviders []string `json:"configured_providers"` // List of provider names with credentials
Providers []AIProviderDefinitionResponse `json:"providers"` // Canonical provider registry metadata
// Cost controls
@ -2469,6 +2472,7 @@ type AISettingsUpdateRequest struct {
OllamaPassword *string `json:"ollama_password,omitempty"` // Set Ollama Basic Auth password
OllamaKeepAlive *string `json:"ollama_keep_alive,omitempty"` // Set Ollama keep_alive; empty uses server default
OpenAIBaseURL *string `json:"openai_base_url,omitempty"` // Set custom OpenAI base URL
ZaiBaseURL *string `json:"zai_base_url,omitempty"` // Set custom Z.ai base URL (e.g. coding endpoint)
// Clear flags for removing credentials
ClearAnthropicKey *bool `json:"clear_anthropic_key,omitempty"` // Clear Anthropic API key
ClearOpenAIKey *bool `json:"clear_openai_key,omitempty"` // Clear OpenAI API key
@ -2620,6 +2624,7 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R
OllamaPasswordSet: settings.OllamaPassword != "",
OllamaKeepAlive: settings.GetOllamaKeepAlive(),
OpenAIBaseURL: settings.OpenAIBaseURL,
ZaiBaseURL: settings.ZaiBaseURL,
ConfiguredProviders: settings.GetConfiguredProviders(),
Providers: aiProviderDefinitionResponses(settings),
CostBudgetUSD30d: settings.CostBudgetUSD30d,
@ -2810,6 +2815,9 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
if req.OpenAIBaseURL != nil {
settings.OpenAIBaseURL = strings.TrimSpace(*req.OpenAIBaseURL)
}
if req.ZaiBaseURL != nil {
settings.ZaiBaseURL = strings.TrimSpace(*req.ZaiBaseURL)
}
if req.Enabled != nil {
// Only allow enabling if at least one BYOK/local provider is configured.
@ -3110,6 +3118,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
OllamaPasswordSet: settings.OllamaPassword != "",
OllamaKeepAlive: settings.GetOllamaKeepAlive(),
OpenAIBaseURL: settings.OpenAIBaseURL,
ZaiBaseURL: settings.ZaiBaseURL,
ConfiguredProviders: settings.GetConfiguredProviders(),
Providers: aiProviderDefinitionResponses(settings),
RequestTimeoutSeconds: settings.RequestTimeoutSeconds,

View file

@ -639,6 +639,7 @@ func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) {
OllamaUsername: ptr("unai"),
OllamaPassword: ptr("secret"),
OllamaKeepAlive: ptr("24h"),
ZaiBaseURL: ptr("https://api.z.ai/api/coding/paas/v4"),
})
req := newLoopbackRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body))
rec := httptest.NewRecorder()
@ -658,6 +659,9 @@ func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) {
if resp.OllamaBaseURL != "http://localhost:11434" {
t.Fatalf("unexpected ollama base url: %+v", resp)
}
if resp.ZaiBaseURL != "https://api.z.ai/api/coding/paas/v4" {
t.Fatalf("unexpected zai base url: %+v", resp)
}
if resp.OllamaUsername != "unai" || !resp.OllamaPasswordSet {
t.Fatalf("expected ollama auth state in response, got %+v", resp)
}

View file

@ -43,6 +43,7 @@ type AIConfig struct {
DeepSeekAPIKey string `json:"deepseek_api_key,omitempty"` // DeepSeek API key
GeminiAPIKey string `json:"gemini_api_key,omitempty"` // Google Gemini API key
ZaiAPIKey string `json:"zai_api_key,omitempty"` // Z.ai (Zhipu GLM) API key
ZaiBaseURL string `json:"zai_base_url,omitempty"` // Custom Z.ai OpenAI-compatible base URL (e.g. coding endpoint)
GroqAPIKey string `json:"groq_api_key,omitempty"` // Groq API key
MistralAPIKey string `json:"mistral_api_key,omitempty"` // Mistral API key
CerebrasAPIKey string `json:"cerebras_api_key,omitempty"` // Cerebras API key
@ -466,12 +467,14 @@ func (c *AIConfig) GetBaseURLForProvider(provider string) string {
if c != nil && c.OllamaBaseURL != "" {
return c.OllamaBaseURL
}
return DefaultOllamaBaseURL
case AIProviderOpenAI:
if c != nil && c.OpenAIBaseURL != "" {
return c.OpenAIBaseURL
}
return "" // Uses default OpenAI URL
case AIProviderZai:
if c != nil && c.ZaiBaseURL != "" {
return c.ZaiBaseURL
}
}
if def, ok := LookupAIProviderDefinition(provider); ok {
return def.DefaultBaseURL

View file

@ -542,6 +542,7 @@ func TestAIConfig_GetBaseURLForProvider(t *testing.T) {
config := AIConfig{
OllamaBaseURL: "http://custom:11434",
OpenAIBaseURL: "https://custom-openai.com",
ZaiBaseURL: "https://api.z.ai/api/coding/paas/v4",
}
tests := []struct {
@ -553,7 +554,7 @@ func TestAIConfig_GetBaseURLForProvider(t *testing.T) {
{AIProviderOpenRouter, DefaultOpenRouterBaseURL},
{AIProviderDeepSeek, DefaultDeepSeekBaseURL},
{AIProviderGemini, DefaultGeminiBaseURL},
{AIProviderZai, DefaultZaiBaseURL},
{AIProviderZai, "https://api.z.ai/api/coding/paas/v4"},
{AIProviderGroq, DefaultGroqBaseURL},
{AIProviderMistral, DefaultMistralBaseURL},
{AIProviderCerebras, DefaultCerebrasBaseURL},
@ -580,6 +581,9 @@ func TestAIConfig_GetBaseURLForProvider(t *testing.T) {
if url := cfg.GetBaseURLForProvider(AIProviderOpenAI); url != "" {
t.Errorf("openai default = %q, want empty", url)
}
if url := cfg.GetBaseURLForProvider(AIProviderZai); url != DefaultZaiBaseURL {
t.Errorf("zai default = %q, want %q", url, DefaultZaiBaseURL)
}
})
}

View file

@ -159,6 +159,7 @@ func aiProviderDefinitions() []AIProviderDefinition {
APIKeyField: "zai_api_key",
ConfiguredField: "zai_configured",
ClearKeyField: "clear_zai_key",
BaseURLField: "zai_base_url",
RequiresAPIKey: true,
UserConfigurable: true,
ModelsDevProviderID: "zai",