diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1fb67935..bb176612 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: 1.26.1 + go-version: 1.26.2 cache: true - name: Run GoReleaser diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1992e317..fa0f9538 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ concurrency: cancel-in-progress: true env: - GO_VERSION: "1.26.1" + GO_VERSION: "1.26.2" # Restrict token permissions to minimum required permissions: diff --git a/CLAUDE.md b/CLAUDE.md index 2fbc7b69..11aefb4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Guidance for AI models (like Claude) working with this codebase. **GOModel** is a high-performance AI gateway in Go that routes requests to multiple AI model providers (OpenAI, Anthropic, Gemini, Groq, xAI, Oracle, Ollama). LiteLLM killer. -**Go:** 1.26.1 +**Go:** 1.26.2 **Repo:** https://github.com/ENTERPILOT/GOModel - **Stage:** Development - backward compatibility is not a concern diff --git a/Dockerfile b/Dockerfile index f2c9ba1f..6abd1226 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build stage — run on the build host's native arch for speed, cross-compile for target -FROM --platform=$BUILDPLATFORM golang:1.26.1-alpine3.23 AS builder +FROM --platform=$BUILDPLATFORM golang:1.26.2-alpine3.23 AS builder ARG TARGETOS ARG TARGETARCH diff --git a/README.md b/README.md index 64a63cd1..b65d2c85 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Example model identifiers are illustrative and subject to change; consult provid ### Running from Source -**Prerequisites:** Go 1.26+ +**Prerequisites:** Go 1.26.2+ 1. Create a `.env` file: diff --git a/docs/2026-03-23_benchmark_scripts/README.md b/docs/2026-03-23_benchmark_scripts/README.md index bf0f2730..bf0a3d3c 100644 --- a/docs/2026-03-23_benchmark_scripts/README.md +++ b/docs/2026-03-23_benchmark_scripts/README.md @@ -28,7 +28,7 @@ gateway in the middle. ## Prerequisites -- Go 1.26+ +- Go 1.26.2+ - Python 3.10+ - `hey` - `litellm` diff --git a/docs/about/benchmarks.mdx b/docs/about/benchmarks.mdx index 0d3853ba..23585152 100644 --- a/docs/about/benchmarks.mdx +++ b/docs/about/benchmarks.mdx @@ -76,7 +76,7 @@ All the tooling used in the published benchmark is available in this repository. ### Prerequisites -- Go 1.26+ +- Go 1.26.2+ - Python 3.10+ with `matplotlib` and `numpy` - `jq`, `curl` - A Groq API key (or any OpenAI-compatible provider — adjust the script) diff --git a/go.mod b/go.mod index 39a44280..e5da5a6b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module gomodel -go 1.26.1 +go 1.26.2 require ( github.com/andybalholm/brotli v1.2.1 diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css index b93e13ac..fd611d40 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -92,6 +92,10 @@ * { box-sizing: border-box; margin: 0; padding: 0; } +[x-cloak] { + display: none !important; +} + body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); @@ -193,29 +197,169 @@ body { border-top: 1px solid var(--border); } -.api-key-section label { - display: block; - font-size: 11px; +.api-key-section { + display: grid; + gap: 8px; +} + +.api-key-open-btn { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 10px; + background: transparent; + border: 1px solid var(--accent); + border-radius: var(--radius); + color: var(--accent); + font-size: 13px; + font-family: inherit; font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; + cursor: pointer; + transition: background-color 0.15s, border-color 0.15s; +} + +.api-key-open-icon { + width: 15px; + height: 15px; + flex: 0 0 15px; +} + +.api-key-open-btn:hover { + background: color-mix(in srgb, var(--accent) 10%, transparent); + border-color: color-mix(in srgb, var(--accent) 78%, var(--text)); + color: color-mix(in srgb, var(--accent) 78%, var(--text)); +} + +.api-key-open-btn:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 36%, transparent); + outline-offset: 2px; +} + +.auth-dialog-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.48); + z-index: 80; +} + +.auth-dialog-shell { + position: fixed; + inset: 0; + z-index: 90; + display: grid; + place-items: center; + padding: 20px; +} + +.auth-dialog { + width: min(440px, 100%); + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.38); + padding: 22px; +} + +.auth-dialog-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.auth-dialog h2 { + font-size: 22px; + line-height: 1.2; + margin-top: 2px; +} + +.auth-dialog-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; color: var(--text-muted); - margin-bottom: 6px; + cursor: pointer; + font: inherit; + line-height: 1; +} + +.auth-dialog-close:hover { + color: var(--text); + background: var(--bg-surface-hover); +} + +.auth-dialog-hint { + color: var(--text-muted); + font-size: 13px; } -.api-key-section input { +.auth-dialog-error { + color: var(--danger); + font-size: 13px; + font-weight: 600; +} + +.auth-dialog-form { + display: grid; + gap: 10px; + margin-top: 18px; +} + +.auth-dialog-input-shell { + position: relative; +} + +.auth-dialog-input-icon { + position: absolute; + left: 12px; + top: 50%; + width: 16px; + height: 16px; + color: var(--text-muted); + pointer-events: none; + transform: translateY(-50%); +} + +.auth-dialog-input-shell:focus-within .auth-dialog-input-icon { + color: var(--accent); +} + +.auth-dialog-input { width: 100%; - padding: 8px 10px; + padding: 11px 12px 11px 38px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); - font-size: 13px; + font-size: 14px; + font-family: inherit; outline: none; } -.api-key-section input:focus { +.auth-dialog-input:focus { border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); +} + +.auth-dialog-submit-icon { + width: 16px; + height: 16px; + flex: 0 0 16px; +} + +.auth-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; } /* Theme toggle — compact pill */ @@ -1083,6 +1227,14 @@ body { flex-wrap: wrap; } +.auth-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + /* Table */ .table-toolbar { display: flex; @@ -2000,8 +2152,18 @@ td.col-price { } .usage-log-select { - padding: 8px 12px; - background: var(--bg); + appearance: none; + -webkit-appearance: none; + padding: 8px 34px 8px 12px; + background-color: var(--bg); + background-image: + linear-gradient(45deg, transparent 50%, currentcolor 50%), + linear-gradient(135deg, currentcolor 50%, transparent 50%); + background-position: + calc(100% - 17px) 50%, + calc(100% - 12px) 50%; + background-repeat: no-repeat; + background-size: 5px 5px; border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); @@ -2012,6 +2174,11 @@ td.col-price { min-width: 140px; } +.usage-log-select:disabled { + cursor: default; + opacity: 0.65; +} + .usage-log-select:focus { border-color: var(--accent); } @@ -3188,12 +3355,32 @@ body.conversation-drawer-open { .sidebar-header h1, .badge { display: none; } .sidebar-nav .nav-item { justify-content: center; padding: 10px; } .sidebar-nav .nav-item span { display: none; } - .sidebar-footer { padding: 8px; } - .sidebar-footer .api-key-section { display: none; } + .sidebar-footer { display: grid; gap: 8px; padding: 8px; } + .sidebar-footer .api-key-section { display: grid; } + .sidebar.sidebar-collapsed .sidebar-footer .api-key-section { display: grid; } + .sidebar-footer .api-key-open-btn { + width: 36px; + height: 36px; + min-height: 36px; + justify-self: center; + padding: 0; + } + .sidebar-footer .api-key-open-btn span { + display: none; + } + .sidebar-footer .api-key-open-icon { + width: 16px; + height: 16px; + flex-basis: 16px; + } .sidebar-footer .theme-toggle { display: none; } .sidebar-footer .theme-toggle-mobile { display: flex; margin: 0 auto; } .sidebar-toggle { display: none; } .content { width: 100%; margin: 0 auto; padding: 20px; } + .auth-dialog-shell { align-items: end; padding: 12px; } + .auth-dialog { padding: 18px; } + .auth-dialog-actions { flex-direction: column-reverse; } + .auth-dialog-actions .pagination-btn { width: 100%; } .cards { grid-template-columns: repeat(2, 1fr); } .provider-status-flag { diff --git a/internal/admin/dashboard/static/js/dashboard.js b/internal/admin/dashboard/static/js/dashboard.js index 27d1649f..8ec6a6ed 100644 --- a/internal/admin/dashboard/static/js/dashboard.js +++ b/internal/admin/dashboard/static/js/dashboard.js @@ -1,6 +1,8 @@ // GOModel Dashboard — Alpine.js + Chart.js logic function dashboard() { + const STALE_AUTH_RESPONSE = 'STALE_AUTH'; + function resolveModuleFactory(factory, windowName) { if (typeof factory === 'function') { return factory; @@ -28,6 +30,8 @@ function dashboard() { authError: false, needsAuth: false, apiKey: '', + authDialogOpen: false, + authRequestGeneration: 0, theme: 'system', sidebarCollapsed: false, settingsSubpage: 'general', @@ -172,7 +176,7 @@ function dashboard() { if (typeof this.initProviderStatusPreferences === 'function') { this.initProviderStatusPreferences(); } - this.apiKey = localStorage.getItem('gomodel_api_key') || ''; + this.apiKey = this.normalizeApiKey(localStorage.getItem('gomodel_api_key') || ''); this.theme = localStorage.getItem('gomodel_theme') || 'system'; this.sidebarCollapsed = localStorage.getItem('gomodel_sidebar_collapsed') === 'true'; this.applyTheme(); @@ -263,7 +267,21 @@ function dashboard() { this.renderUserPathChart(); }, + normalizeApiKey(value) { + const key = String(value || '').trim(); + if (/^Bearer\s*$/i.test(key)) { + return ''; + } + const match = key.match(/^Bearer\s+(.+)$/i); + return match ? match[1].trim() : key; + }, + + hasApiKey() { + return this.normalizeApiKey(this.apiKey) !== ''; + }, + saveApiKey() { + this.apiKey = this.normalizeApiKey(this.apiKey); if (this.apiKey) { localStorage.setItem('gomodel_api_key', this.apiKey); } else { @@ -271,10 +289,56 @@ function dashboard() { } }, + requestOptions(options) { + const request = { ...(options || {}) }; + request.headers = this.headers(); + request.authGeneration = this.authRequestGeneration; + return request; + }, + + isStaleAuthResponse(request) { + return request && + typeof request.authGeneration === 'number' && + request.authGeneration < this.authRequestGeneration; + }, + + openAuthDialog() { + this.authDialogOpen = true; + setTimeout(() => { + const input = document.getElementById('authDialogApiKey'); + if (input && typeof input.focus === 'function') { + input.focus(); + } + }, 0); + }, + + closeAuthDialog() { + this.authDialogOpen = false; + }, + + submitApiKey() { + const apiKey = this.normalizeApiKey(this.apiKey); + if (!apiKey) { + this.apiKey = ''; + this.authError = true; + this.needsAuth = true; + this.openAuthDialog(); + return; + } + this.apiKey = apiKey; + this.saveApiKey(); + this.authRequestGeneration++; + this.authError = false; + this.needsAuth = false; + this.closeAuthDialog(); + this.fetchAll(); + }, + headers() { const h = { 'Content-Type': 'application/json' }; - if (this.apiKey) { - h.Authorization = 'Bearer ' + this.apiKey; + const apiKey = this.normalizeApiKey(this.apiKey); + if (apiKey) { + h.Authorization = 'Bearer ' + apiKey; } if (typeof this.effectiveTimezone === 'function') { h['X-GoModel-Timezone'] = this.effectiveTimezone(); @@ -315,6 +379,14 @@ function dashboard() { return Boolean(error) && (error.name === 'AbortError' || error.code === 20); }, + staleAuthResponseResult() { + return STALE_AUTH_RESPONSE; + }, + + isStaleAuthFetchResult(result) { + return result === STALE_AUTH_RESPONSE; + }, + dashboardDataFetches() { const requests = [this.fetchUsage(), this.fetchModels(), this.fetchCategories()]; if (typeof this.fetchProviderStatus === 'function') { @@ -354,11 +426,15 @@ function dashboard() { this.runtimeRefreshReport = null; try { - const res = await fetch('/admin/api/v1/runtime/refresh', { + const request = this.requestOptions({ method: 'POST', - headers: this.headers() }); - if (!this.handleFetchResponse(res, 'runtime refresh')) { + const res = await fetch('/admin/api/v1/runtime/refresh', request); + const handled = this.handleFetchResponse(res, 'runtime refresh', request); + if (this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { this.runtimeRefreshNotice = 'Runtime refresh failed.'; this.runtimeRefreshError = this.runtimeRefreshNotice; return; @@ -433,10 +509,14 @@ function dashboard() { return name + ': ' + status + ' - ' + detail; }, - handleFetchResponse(res, label) { + handleFetchResponse(res, label, request) { if (res.status === 401) { + if (this.isStaleAuthResponse(request)) { + return STALE_AUTH_RESPONSE; + } this.authError = true; this.needsAuth = true; + this.openAuthDialog(); return false; } if (!res.ok) { @@ -457,7 +537,7 @@ function dashboard() { async fetchModels() { const controller = this._startAbortableRequest('_modelsFetchController'); const isCurrentRequest = () => this._isCurrentAbortableRequest('_modelsFetchController', controller); - const options = { headers: this.headers() }; + const options = this.requestOptions(); if (controller) { options.signal = controller.signal; } @@ -472,7 +552,11 @@ function dashboard() { if (!isCurrentRequest()) { return; } - if (!this.handleFetchResponse(res, 'models')) { + const handled = this.handleFetchResponse(res, 'models', options); + if (this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { if (!isCurrentRequest()) { return; } @@ -506,9 +590,14 @@ function dashboard() { }, async fetchCategories() { + const request = this.requestOptions(); try { - const res = await fetch('/admin/api/v1/models/categories', { headers: this.headers() }); - if (!this.handleFetchResponse(res, 'categories')) { + const res = await fetch('/admin/api/v1/models/categories', request); + const handled = this.handleFetchResponse(res, 'categories', request); + if (this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { this.categories = []; return; } diff --git a/internal/admin/dashboard/static/js/modules/aliases.js b/internal/admin/dashboard/static/js/modules/aliases.js index 1b104892..2933a66a 100644 --- a/internal/admin/dashboard/static/js/modules/aliases.js +++ b/internal/admin/dashboard/static/js/modules/aliases.js @@ -146,19 +146,30 @@ }; }, + adminRequestOptions(options) { + return typeof this.requestOptions === 'function' + ? this.requestOptions(options) + : { ...(options || {}), headers: this.headers() }; + }, + async fetchAliases() { this.aliasLoading = true; this.aliasError = ''; try { - const res = await fetch('/admin/api/v1/aliases', { headers: this.headers() }); + const request = this.adminRequestOptions(); + const res = await fetch('/admin/api/v1/aliases', request); if (res.status === 503) { this.aliasesAvailable = false; this.aliases = []; this.syncDisplayModels(); return; } + const handled = this.handleFetchResponse(res, 'aliases', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } this.aliasesAvailable = true; - if (!this.handleFetchResponse(res, 'aliases')) { + if (!handled) { this.aliases = []; this.syncDisplayModels(); return; @@ -179,15 +190,20 @@ async fetchModelOverrides() { this.modelOverrideError = ''; try { - const res = await fetch('/admin/api/v1/model-overrides', { headers: this.headers() }); + const request = this.adminRequestOptions(); + const res = await fetch('/admin/api/v1/model-overrides', request); if (res.status === 503) { this.modelOverridesAvailable = false; this.modelOverrideViews = []; this.syncDisplayModels(); return; } + const handled = this.handleFetchResponse(res, 'model overrides', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } this.modelOverridesAvailable = true; - if (!this.handleFetchResponse(res, 'model overrides')) { + if (!handled) { this.modelOverrideViews = []; this.syncDisplayModels(); return; @@ -685,24 +701,24 @@ }; try { - const res = await fetch('/admin/api/v1/aliases/' + encodeURIComponent(alias.name), { + const request = this.adminRequestOptions({ method: 'PUT', - headers: this.headers(), body: JSON.stringify(payload) }); + const res = await fetch('/admin/api/v1/aliases/' + encodeURIComponent(alias.name), request); if (res.status === 503) { this.aliasesAvailable = false; this.aliasError = 'Aliases feature is unavailable.'; return; } - if (res.status === 401) { - this.authError = true; - this.needsAuth = true; - this.aliasError = 'Authentication required.'; + const handled = this.handleFetchResponse(res, 'alias state', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { return; } - if (!res.ok) { - this.aliasError = await this.aliasResponseMessage(res, 'Failed to update alias state.'); + if (!handled) { + this.aliasError = res.status === 401 + ? 'Authentication required.' + : await this.aliasResponseMessage(res, 'Failed to update alias state.'); return; } @@ -886,37 +902,45 @@ }; try { - const saveRes = await fetch('/admin/api/v1/aliases/' + encodeURIComponent(name), { + const saveRequest = this.adminRequestOptions({ method: 'PUT', - headers: this.headers(), body: JSON.stringify(payload) }); + const saveRes = await fetch('/admin/api/v1/aliases/' + encodeURIComponent(name), saveRequest); if (saveRes.status === 503) { this.aliasesAvailable = false; this.aliasFormError = 'Aliases feature is unavailable.'; return; } - if (saveRes.status === 401) { - this.authError = true; - this.needsAuth = true; - this.aliasFormError = 'Authentication required.'; + const saveHandled = this.handleFetchResponse(saveRes, 'alias', saveRequest); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(saveHandled)) { return; } - if (!saveRes.ok) { - this.aliasFormError = await this.aliasResponseMessage(saveRes, 'Failed to save alias.'); + if (!saveHandled) { + this.aliasFormError = saveRes.status === 401 + ? 'Authentication required.' + : await this.aliasResponseMessage(saveRes, 'Failed to save alias.'); return; } if (originalName && originalName !== name) { - const deleteRes = await fetch('/admin/api/v1/aliases/' + encodeURIComponent(originalName), { - method: 'DELETE', - headers: this.headers() + const deleteRequest = this.adminRequestOptions({ + method: 'DELETE' }); - if (deleteRes.status !== 404 && !deleteRes.ok) { - this.aliasFormError = await this.aliasResponseMessage(deleteRes, 'The alias was saved with the new name, but the previous alias could not be removed.'); - await this.fetchAliases(); - return; + const deleteRes = await fetch('/admin/api/v1/aliases/' + encodeURIComponent(originalName), deleteRequest); + if (deleteRes.status !== 404) { + const deleteHandled = this.handleFetchResponse(deleteRes, 'previous alias', deleteRequest); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(deleteHandled)) { + return; + } + if (!deleteHandled) { + this.aliasFormError = deleteRes.status === 401 + ? 'Authentication required.' + : await this.aliasResponseMessage(deleteRes, 'The alias was saved with the new name, but the previous alias could not be removed.'); + await this.fetchAliases(); + return; + } } } @@ -943,23 +967,23 @@ this.aliasFormError = ''; try { - const res = await fetch('/admin/api/v1/aliases/' + encodeURIComponent(alias.name), { - method: 'DELETE', - headers: this.headers() + const request = this.adminRequestOptions({ + method: 'DELETE' }); + const res = await fetch('/admin/api/v1/aliases/' + encodeURIComponent(alias.name), request); if (res.status === 503) { this.aliasesAvailable = false; this.aliasError = 'Aliases feature is unavailable.'; return; } - if (res.status === 401) { - this.authError = true; - this.needsAuth = true; - this.aliasError = 'Authentication required.'; + const handled = this.handleFetchResponse(res, 'alias', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { return; } - if (!res.ok) { - this.aliasError = await this.aliasResponseMessage(res, 'Failed to remove alias.'); + if (!handled) { + this.aliasError = res.status === 401 + ? 'Authentication required.' + : await this.aliasResponseMessage(res, 'Failed to remove alias.'); return; } @@ -998,27 +1022,27 @@ const payload = { user_paths: userPaths }; try { - const res = await fetch('/admin/api/v1/model-overrides/' + encodeURIComponent(selector), { + const request = this.adminRequestOptions({ method: 'PUT', - headers: this.headers(), body: JSON.stringify(payload) }); + const res = await fetch('/admin/api/v1/model-overrides/' + encodeURIComponent(selector), request); if (res.status === 503) { this.modelOverridesAvailable = false; this.modelOverrideError = 'Model overrides feature is unavailable.'; return; } - this.modelOverridesAvailable = true; - if (res.status === 401) { - this.authError = true; - this.needsAuth = true; - this.modelOverrideError = 'Authentication required.'; + const handled = this.handleFetchResponse(res, 'model access', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { return; } - if (!res.ok) { - this.modelOverrideError = await this.aliasResponseMessage(res, 'Failed to save model access.'); + if (!handled) { + this.modelOverrideError = res.status === 401 + ? 'Authentication required.' + : await this.aliasResponseMessage(res, 'Failed to save model access.'); return; } + this.modelOverridesAvailable = true; await Promise.all([this.fetchModels(), this.fetchModelOverrides()]); this.closeModelOverrideForm(); @@ -1045,26 +1069,28 @@ this.modelOverrideNotice = ''; try { - const res = await fetch('/admin/api/v1/model-overrides/' + encodeURIComponent(selector), { - method: 'DELETE', - headers: this.headers() + const request = this.adminRequestOptions({ + method: 'DELETE' }); + const res = await fetch('/admin/api/v1/model-overrides/' + encodeURIComponent(selector), request); if (res.status === 503) { this.modelOverridesAvailable = false; this.modelOverrideError = 'Model overrides feature is unavailable.'; return; } - this.modelOverridesAvailable = true; - if (res.status === 401) { - this.authError = true; - this.needsAuth = true; - this.modelOverrideError = 'Authentication required.'; - return; - } - if (res.status !== 404 && !res.ok) { - this.modelOverrideError = await this.aliasResponseMessage(res, 'Failed to remove model override.'); - return; + if (res.status !== 404) { + const handled = this.handleFetchResponse(res, 'model access', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { + this.modelOverrideError = res.status === 401 + ? 'Authentication required.' + : await this.aliasResponseMessage(res, 'Failed to remove model override.'); + return; + } } + this.modelOverridesAvailable = true; await Promise.all([this.fetchModels(), this.fetchModelOverrides()]); this.closeModelOverrideForm(); diff --git a/internal/admin/dashboard/static/js/modules/aliases.test.js b/internal/admin/dashboard/static/js/modules/aliases.test.js index 18aaa9a1..76279b4b 100644 --- a/internal/admin/dashboard/static/js/modules/aliases.test.js +++ b/internal/admin/dashboard/static/js/modules/aliases.test.js @@ -4,19 +4,23 @@ const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); -function loadAliasesModuleFactory() { +function loadAliasesModuleFactory(overrides = {}) { const source = fs.readFileSync(path.join(__dirname, 'aliases.js'), 'utf8'); + const window = { + ...(overrides.window || {}) + }; const context = { - window: {}, - console + console, + ...overrides.context, + window }; vm.createContext(context); vm.runInContext(source, context); return context.window.dashboardAliasesModule; } -function createAliasesModule() { - const factory = loadAliasesModuleFactory(); +function createAliasesModule(overrides) { + const factory = loadAliasesModuleFactory(overrides); return factory(); } @@ -308,3 +312,133 @@ test('openGlobalModelOverrideEdit opens the access editor with slash selector', assert.equal(module.modelOverrideFormEffectiveEnabled, true); assert.equal(module.modelOverrideForm.user_paths, '/team/alpha'); }); + +test('alias write paths use generation-aware request handling for stale auth responses', async() => { + const scenarios = [ + { + name: 'toggleAliasEnabled', + run(module) { + return module.toggleAliasEnabled({ + name: 'short', + target_model: 'openai/gpt-4o', + description: '', + enabled: true + }); + }, + errorKey: 'aliasError' + }, + { + name: 'submitAliasForm', + setup(module) { + module.aliasForm = { + name: 'short', + target_model: 'openai/gpt-4o', + description: '', + enabled: true + }; + module.aliasFormOriginalName = ''; + }, + run(module) { + return module.submitAliasForm(); + }, + errorKey: 'aliasFormError' + }, + { + name: 'submitModelOverrideForm', + setup(module) { + module.modelOverrideForm = { + selector: 'openai/gpt-4o', + user_paths: '/team/alpha' + }; + }, + run(module) { + return module.submitModelOverrideForm(); + }, + errorKey: 'modelOverrideError' + }, + { + name: 'deleteModelOverride', + setup(module) { + module.modelOverrideForm = { + selector: 'openai/gpt-4o', + user_paths: '/team/alpha' + }; + module.modelOverrideFormHasExistingOverride = true; + }, + run(module) { + return module.deleteModelOverride(); + }, + errorKey: 'modelOverrideError' + } + ]; + + for (const scenario of scenarios) { + const fetchCalls = []; + const handledCalls = []; + const module = createAliasesModule({ + context: { + fetch: async(url, request) => { + fetchCalls.push({ url, request }); + return { + ok: false, + status: 401, + statusText: 'Unauthorized', + json: async() => ({}) + }; + } + }, + window: { + confirm: () => true + } + }); + + Object.assign(module, { + aliases: [], + models: [], + modelOverrideViews: [], + aliasesAvailable: true, + modelOverridesAvailable: true, + needsAuth: false, + authError: false, + requestOptions(options) { + return { + ...(options || {}), + headers: { Authorization: 'Bearer current-token' }, + authGeneration: 3 + }; + }, + headers() { + return { Authorization: 'Bearer current-token' }; + }, + handleFetchResponse(res, label, request) { + handledCalls.push({ res, label, request }); + return 'STALE_AUTH'; + }, + isStaleAuthFetchResult(result) { + return result === 'STALE_AUTH'; + }, + fetchAliases() { + throw new Error('fetchAliases should not run for stale auth in ' + scenario.name); + }, + fetchModels() { + throw new Error('fetchModels should not run for stale auth in ' + scenario.name); + }, + fetchModelOverrides() { + throw new Error('fetchModelOverrides should not run for stale auth in ' + scenario.name); + } + }); + if (scenario.setup) { + scenario.setup(module); + } + + await scenario.run(module); + + assert.equal(fetchCalls.length, 1, scenario.name); + assert.equal(handledCalls.length, 1, scenario.name); + assert.strictEqual(handledCalls[0].request, fetchCalls[0].request, scenario.name); + assert.equal(fetchCalls[0].request.authGeneration, 3, scenario.name); + assert.equal(module.needsAuth, false, scenario.name); + assert.equal(module.authError, false, scenario.name); + assert.equal(module[scenario.errorKey], '', scenario.name); + } +}); diff --git a/internal/admin/dashboard/static/js/modules/audit-list.js b/internal/admin/dashboard/static/js/modules/audit-list.js index 90a391ca..ec96f89e 100644 --- a/internal/admin/dashboard/static/js/modules/audit-list.js +++ b/internal/admin/dashboard/static/js/modules/audit-list.js @@ -27,8 +27,13 @@ if (this.auditStatusCode) qs += '&status_code=' + encodeURIComponent(this.auditStatusCode); if (this.auditStream) qs += '&stream=' + encodeURIComponent(this.auditStream); - const res = await fetch('/admin/api/v1/audit/log?' + qs, { headers: this.headers() }); - if (!this.handleFetchResponse(res, 'audit log')) { + const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; + const res = await fetch('/admin/api/v1/audit/log?' + qs, request); + const handled = this.handleFetchResponse(res, 'audit log', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { if (requestToken !== this.auditFetchToken) return; this.auditLog = { entries: [], total: 0, limit: 25, offset: 0 }; return; diff --git a/internal/admin/dashboard/static/js/modules/auth-keys.js b/internal/admin/dashboard/static/js/modules/auth-keys.js index 5ee6bcec..8bc0e587 100644 --- a/internal/admin/dashboard/static/js/modules/auth-keys.js +++ b/internal/admin/dashboard/static/js/modules/auth-keys.js @@ -90,14 +90,19 @@ this.authKeysLoading = true; this.authKeyError = ''; try { - const res = await fetch('/admin/api/v1/auth-keys', { headers: this.headers() }); + const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; + const res = await fetch('/admin/api/v1/auth-keys', request); if (res.status === 503) { this.authKeysAvailable = false; this.authKeys = []; return; } + const handled = this.handleFetchResponse(res, 'auth keys', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } this.authKeysAvailable = true; - if (!this.handleFetchResponse(res, 'auth keys')) { + if (!handled) { if (res.status !== 401) { this.authKeyError = await this._authKeyResponseMessage(res, 'Unable to load API keys.'); } diff --git a/internal/admin/dashboard/static/js/modules/contribution-calendar.js b/internal/admin/dashboard/static/js/modules/contribution-calendar.js index 5c52d0dc..896b6a4c 100644 --- a/internal/admin/dashboard/static/js/modules/contribution-calendar.js +++ b/internal/admin/dashboard/static/js/modules/contribution-calendar.js @@ -19,18 +19,21 @@ } return this._calendarFetchController === controller && !controller.signal.aborted; }; - const options = { headers: this.headers() }; - if (controller) { - options.signal = controller.signal; - } - this.calendarLoading = true; try { + const options = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; + if (controller) { + options.signal = controller.signal; + } const res = await fetch('/admin/api/v1/usage/daily?days=365&interval=daily', options); if (!isCurrentRequest()) { return; } - if (!this.handleFetchResponse(res, 'calendar')) { + const handled = this.handleFetchResponse(res, 'calendar', options); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { if (!isCurrentRequest()) { return; } diff --git a/internal/admin/dashboard/static/js/modules/conversation-drawer.js b/internal/admin/dashboard/static/js/modules/conversation-drawer.js index 9b182cda..c58f2f99 100644 --- a/internal/admin/dashboard/static/js/modules/conversation-drawer.js +++ b/internal/admin/dashboard/static/js/modules/conversation-drawer.js @@ -107,11 +107,16 @@ async fetchConversation(logID, requestToken) { try { const qs = 'log_id=' + encodeURIComponent(logID) + '&limit=120'; - const res = await fetch('/admin/api/v1/audit/conversation?' + qs, { headers: this.headers() }); + const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; + const res = await fetch('/admin/api/v1/audit/conversation?' + qs, request); if (requestToken !== this.conversationRequestToken) return; - if (!this.handleFetchResponse(res, 'audit conversation')) { + const handled = this.handleFetchResponse(res, 'audit conversation', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { this.conversationError = 'Unable to load interactions.'; this.conversationEntries = []; this.conversationMessages = []; diff --git a/internal/admin/dashboard/static/js/modules/dashboard-display.test.js b/internal/admin/dashboard/static/js/modules/dashboard-display.test.js index dceeb9c3..0c5c931b 100644 --- a/internal/admin/dashboard/static/js/modules/dashboard-display.test.js +++ b/internal/admin/dashboard/static/js/modules/dashboard-display.test.js @@ -4,16 +4,26 @@ const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); +function createLocalStorage(initial = {}) { + const values = { ...initial }; + return { + getItem(key) { + return Object.prototype.hasOwnProperty.call(values, key) ? values[key] : null; + }, + setItem(key, value) { + values[key] = String(value); + }, + removeItem(key) { + delete values[key]; + }, + values + }; +} + function loadDashboardApp(overrides = {}) { const dashboardSource = fs.readFileSync(path.join(__dirname, '../dashboard.js'), 'utf8'); const window = { - localStorage: { - getItem() { - return null; - }, - setItem() {}, - removeItem() {} - }, + localStorage: createLocalStorage(), location: { pathname: '/admin/dashboard/usage' }, matchMedia() { return { addEventListener() {} }; @@ -133,3 +143,160 @@ test('system theme media changes rerender all dashboard charts', () => { assert.equal(modelCalls, 1); assert.equal(userPathCalls, 1); }); + +test('unauthorized dashboard responses open the auth dialog', () => { + const app = loadDashboardApp(); + const request = app.requestOptions(); + + const handled = app.handleFetchResponse({ + ok: false, + status: 401, + statusText: 'Unauthorized' + }, 'models', request); + + assert.equal(handled, false); + assert.equal(app.authError, true); + assert.equal(app.needsAuth, true); + assert.equal(app.authDialogOpen, true); +}); + +test('stale unauthorized dashboard responses do not reopen the auth dialog', () => { + const app = loadDashboardApp(); + const staleRequest = app.requestOptions(); + + app.authRequestGeneration++; + app.authError = false; + app.needsAuth = false; + app.authDialogOpen = false; + + const handled = app.handleFetchResponse({ + ok: false, + status: 401, + statusText: 'Unauthorized' + }, 'categories', staleRequest); + + assert.equal(handled, app.staleAuthResponseResult()); + assert.equal(app.authError, false); + assert.equal(app.needsAuth, false); + assert.equal(app.authDialogOpen, false); +}); + +test('stale unauthorized category responses preserve existing categories', async () => { + const existingCategories = [{ category: 'chat', count: 2 }]; + const app = loadDashboardApp({ + context: { + fetch: async () => ({ + ok: false, + status: 401, + statusText: 'Unauthorized' + }) + } + }); + const staleRequest = app.requestOptions(); + app.requestOptions = () => staleRequest; + app.categories = existingCategories; + app.authRequestGeneration++; + + await app.fetchCategories(); + + assert.equal(app.categories, existingCategories); + assert.equal(app.authError, false); + assert.equal(app.needsAuth, false); + assert.equal(app.authDialogOpen, false); +}); + +test('submitApiKey trims bearer input and stores the key before refreshing dashboard data', () => { + const storage = createLocalStorage(); + const app = loadDashboardApp({ + window: { localStorage: storage } + }); + let fetches = 0; + app.fetchAll = () => { + fetches++; + }; + + app.authDialogOpen = true; + app.apiKey = ' Bearer secret-token '; + app.submitApiKey(); + + assert.equal(app.apiKey, 'secret-token'); + assert.equal(app.authRequestGeneration, 1); + assert.equal(storage.getItem('gomodel_api_key'), 'secret-token'); + assert.equal(app.authDialogOpen, false); + assert.equal(fetches, 1); +}); + +test('normalizeApiKey treats a bare bearer scheme as empty', () => { + const app = loadDashboardApp(); + + assert.equal(app.normalizeApiKey('Bearer'), ''); + assert.equal(app.normalizeApiKey('Bearer '), ''); + assert.equal(app.normalizeApiKey('Bearer secret-token'), 'secret-token'); +}); + +test('hasApiKey reflects trimmed bearer input for the sidebar change action', () => { + const app = loadDashboardApp(); + + app.apiKey = ''; + assert.equal(app.hasApiKey(), false); + + app.apiKey = ' Bearer secret-token '; + assert.equal(app.hasApiKey(), true); +}); + +test('submitApiKey rejects blank input without unlocking dashboard', () => { + const storage = createLocalStorage({ gomodel_api_key: 'existing-token' }); + const app = loadDashboardApp({ + window: { localStorage: storage } + }); + let fetches = 0; + app.fetchAll = () => { + fetches++; + }; + + app.authDialogOpen = true; + app.apiKey = ' '; + app.submitApiKey(); + + assert.equal(app.apiKey, ''); + assert.equal(app.authRequestGeneration, 0); + assert.equal(storage.getItem('gomodel_api_key'), 'existing-token'); + assert.equal(app.authError, true); + assert.equal(app.needsAuth, true); + assert.equal(app.authDialogOpen, true); + assert.equal(fetches, 0); +}); + +test('submitApiKey and headers reject a bare bearer scheme without sending authorization', () => { + const storage = createLocalStorage({ gomodel_api_key: 'existing-token' }); + const app = loadDashboardApp({ + window: { localStorage: storage } + }); + let fetches = 0; + app.fetchAll = () => { + fetches++; + }; + + app.authDialogOpen = true; + app.apiKey = 'Bearer '; + app.submitApiKey(); + + assert.equal(app.apiKey, ''); + assert.equal(app.authRequestGeneration, 0); + assert.equal(storage.getItem('gomodel_api_key'), 'existing-token'); + assert.equal(app.authError, true); + assert.equal(app.needsAuth, true); + assert.equal(app.authDialogOpen, true); + assert.equal(fetches, 0); + + app.apiKey = 'Bearer'; + assert.equal(Object.prototype.hasOwnProperty.call(app.headers(), 'Authorization'), false); +}); + +test('headers accept a pasted bearer value without duplicating the prefix', () => { + const app = loadDashboardApp(); + + app.apiKey = 'Bearer secret-token'; + + assert.equal(app.headers().Authorization, 'Bearer secret-token'); +}); diff --git a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js index 27d0fab1..57f8dd46 100644 --- a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js +++ b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js @@ -138,7 +138,7 @@ test('dashboard pages reuse a shared auth banner template', () => { assert.match( authBannerTemplate, - /{{define "auth-banner"}}[\s\S]*x-show="authError"[\s\S]*Authentication required\. Enter your API key in the sidebar to view data\.[\s\S]*{{end}}/ + /{{define "auth-banner"}}[\s\S]*class="alert alert-warning auth-banner"[\s\S]*x-show="authError"[\s\S]*Authentication required for dashboard data\.[\s\S]*@click="openAuthDialog\(\)"[\s\S]*Enter API key[\s\S]*{{end}}/ ); const authBannerCalls = indexTemplate.match(/{{template "auth-banner" \.}}/g) || []; @@ -147,7 +147,92 @@ test('dashboard pages reuse a shared auth banner template', () => { assert.match(indexTemplate, / -
- - +
+
@@ -96,6 +105,59 @@

GOModel

{{template "index" .}}
+ +
+ +
diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 55ef0e07..a4209269 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -144,6 +144,7 @@ func TestRefreshRuntime_SkipsDisabledModelOverrides(t *testing.T) { step := runtimeRefreshStepByName(report.Steps, "model_overrides") if step == nil { t.Fatalf("model_overrides step missing: %+v", report.Steps) + return } if step.Status != admin.RuntimeRefreshStatusSkipped { t.Fatalf("model_overrides step status = %q, want skipped; step=%+v", step.Status, *step) diff --git a/internal/authkeys/service_test.go b/internal/authkeys/service_test.go index 5845bd3b..be93faa4 100644 --- a/internal/authkeys/service_test.go +++ b/internal/authkeys/service_test.go @@ -76,6 +76,7 @@ func TestServiceCreateAuthenticateAndDeactivate(t *testing.T) { } if issued == nil { t.Fatal("Create() = nil, want issued key") + return } if len(issued.Value) <= len(TokenPrefix) || issued.Value[:len(TokenPrefix)] != TokenPrefix { t.Fatalf("issued value = %q, want %q prefix", issued.Value, TokenPrefix) @@ -176,6 +177,7 @@ func TestServiceWriteOperationsIgnoreRefreshReconciliationFailures(t *testing.T) } if issued == nil { t.Fatal("Create() = nil, want issued key") + return } if got, err := service.Authenticate(context.Background(), issued.Value); err != nil || got.ID != issued.ID { t.Fatalf("Authenticate() = (%q, %v), want (%q, nil)", got.ID, err, issued.ID) diff --git a/internal/cache/modelcache/local_test.go b/internal/cache/modelcache/local_test.go index feda1da2..f4399e79 100644 --- a/internal/cache/modelcache/local_test.go +++ b/internal/cache/modelcache/local_test.go @@ -49,6 +49,7 @@ func TestLocalCache(t *testing.T) { } if result == nil { t.Fatal("expected result, got nil") + return } p, ok := result.Providers["openai"] if !ok || len(p.Models) != 1 { diff --git a/internal/cache/modelcache/redis_test.go b/internal/cache/modelcache/redis_test.go index dff75e3b..b3212bee 100644 --- a/internal/cache/modelcache/redis_test.go +++ b/internal/cache/modelcache/redis_test.go @@ -45,6 +45,7 @@ func TestRedisModelCache_GetSet(t *testing.T) { } if got == nil { t.Fatal("expected non-nil ModelCache") + return } if len(got.Providers) != 1 { t.Errorf("Providers: got %d entries, want 1", len(got.Providers)) diff --git a/internal/httpclient/client_test.go b/internal/httpclient/client_test.go index 252e517e..72e7ff2a 100644 --- a/internal/httpclient/client_test.go +++ b/internal/httpclient/client_test.go @@ -118,10 +118,12 @@ func TestNewHTTPClient(t *testing.T) { if client == nil { t.Fatal("Expected client to be non-nil") + return } if client.Transport == nil { t.Fatal("Expected transport to be non-nil") + return } transport, ok := client.Transport.(*http.Transport) @@ -178,10 +180,12 @@ func TestNewDefaultHTTPClient(t *testing.T) { if client == nil { t.Fatal("Expected client to be non-nil") + return } if client.Transport == nil { t.Fatal("Expected transport to be non-nil") + return } transport, ok := client.Transport.(*http.Transport) diff --git a/internal/llmclient/client_test.go b/internal/llmclient/client_test.go index 99152e90..d8041a9d 100644 --- a/internal/llmclient/client_test.go +++ b/internal/llmclient/client_test.go @@ -319,6 +319,7 @@ func TestClient_DoRaw_Success(t *testing.T) { } if resp == nil { t.Fatal("expected response, got nil") + return } if resp.StatusCode != http.StatusOK { t.Errorf("expected status 200, got %d", resp.StatusCode) diff --git a/internal/modeldata/enricher_test.go b/internal/modeldata/enricher_test.go index a27bf90e..7d5ed415 100644 --- a/internal/modeldata/enricher_test.go +++ b/internal/modeldata/enricher_test.go @@ -122,6 +122,7 @@ func TestEnrich_ReverseCustomModelIDLookup(t *testing.T) { meta := accessor.metadata["gpt-4o-2024-08-06"] if meta == nil { t.Fatal("expected gpt-4o-2024-08-06 to be enriched via reverse index") + return } if meta.DisplayName != "GPT-4o" { t.Errorf("DisplayName = %s, want GPT-4o", meta.DisplayName) @@ -158,6 +159,7 @@ func TestEnrich_ProviderModelOverride(t *testing.T) { meta := accessor.metadata["gpt-4o"] if meta == nil { t.Fatal("expected gpt-4o to be enriched") + return } if *meta.ContextWindow != 64000 { t.Errorf("ContextWindow = %d, want 64000 (azure override)", *meta.ContextWindow) diff --git a/internal/modeldata/fetcher_test.go b/internal/modeldata/fetcher_test.go index 56ecdab0..cde2a3f9 100644 --- a/internal/modeldata/fetcher_test.go +++ b/internal/modeldata/fetcher_test.go @@ -38,9 +38,11 @@ func TestFetch_Success(t *testing.T) { } if list == nil { t.Fatal("expected non-nil list") + return } if raw == nil { t.Fatal("expected non-nil raw bytes") + return } if list.Version != 1 { t.Errorf("Version = %d, want 1", list.Version) @@ -157,6 +159,7 @@ func TestParse_BuildsReverseIndex(t *testing.T) { } if list.providerModelByActualID == nil { t.Fatal("expected providerModelByActualID to be built") + return } compositeKey, ok := list.providerModelByActualID["openai/gpt-4o-2024-08-06"] if !ok { diff --git a/internal/modeldata/merger_test.go b/internal/modeldata/merger_test.go index dc2f8294..9f08261e 100644 --- a/internal/modeldata/merger_test.go +++ b/internal/modeldata/merger_test.go @@ -53,6 +53,7 @@ func TestResolve_DirectModelMatch(t *testing.T) { meta := Resolve(list, "openai", "gpt-4o") if meta == nil { t.Fatal("expected non-nil metadata") + return } if meta.DisplayName != "GPT-4o" { @@ -81,6 +82,7 @@ func TestResolve_DirectModelMatch(t *testing.T) { } if meta.Pricing == nil { t.Fatal("expected non-nil pricing") + return } if meta.Pricing.Currency != "USD" { t.Errorf("Currency = %s, want USD", meta.Pricing.Currency) @@ -125,6 +127,7 @@ func TestResolve_ProviderModelOverride(t *testing.T) { meta := Resolve(list, "azure", "gpt-4o") if meta == nil { t.Fatal("expected non-nil metadata") + return } // Provider model should override context_window @@ -169,6 +172,7 @@ func TestResolve_MapsRankingsIntoMetadata(t *testing.T) { meta := Resolve(list, "openai", "gpt-4o") if meta == nil { t.Fatal("expected non-nil metadata") + return } ranking, ok := meta.Rankings["chatbot_arena"] if !ok { @@ -205,6 +209,7 @@ func TestResolve_ProviderModelWithoutBaseModel(t *testing.T) { meta := Resolve(list, "custom", "my-model") if meta == nil { t.Fatal("expected non-nil metadata even without base model") + return } if *meta.ContextWindow != 32000 { @@ -229,6 +234,7 @@ func TestResolve_NilPricing(t *testing.T) { meta := Resolve(list, "openai", "text-moderation") if meta == nil { t.Fatal("expected non-nil metadata") + return } if meta.Pricing != nil { t.Error("expected nil pricing for model without pricing") @@ -273,6 +279,7 @@ func TestResolve_SetsCategoriesFromModes(t *testing.T) { meta := Resolve(list, "openai", tt.modelID) if meta == nil { t.Fatal("expected non-nil metadata") + return } if len(meta.Categories) != len(tt.wantCats) { t.Fatalf("Categories = %v, want %v", meta.Categories, tt.wantCats) @@ -330,6 +337,7 @@ func TestResolve_ThreeLayerMerge(t *testing.T) { meta := Resolve(list, "anthropic", "claude-sonnet-4-20250514") if meta == nil { t.Fatal("expected non-nil metadata") + return } if *meta.MaxOutputTokens != 16384 { t.Errorf("MaxOutputTokens = %d, want 16384 (base)", *meta.MaxOutputTokens) @@ -339,6 +347,7 @@ func TestResolve_ThreeLayerMerge(t *testing.T) { meta = Resolve(list, "bedrock", "claude-sonnet-4-20250514") if meta == nil { t.Fatal("expected non-nil metadata for bedrock") + return } if *meta.MaxOutputTokens != 8192 { t.Errorf("MaxOutputTokens = %d, want 8192 (bedrock override)", *meta.MaxOutputTokens) @@ -377,12 +386,14 @@ func TestResolve_ReverseCustomModelIDLookup(t *testing.T) { meta := Resolve(list, "openai", "gpt-4o-2024-08-06") if meta == nil { t.Fatal("expected non-nil metadata via reverse lookup") + return } if meta.DisplayName != "GPT-4o" { t.Errorf("DisplayName = %s, want GPT-4o", meta.DisplayName) } if meta.Pricing == nil { t.Fatal("expected non-nil pricing via reverse lookup") + return } if *meta.Pricing.InputPerMtok != 2.50 { t.Errorf("InputPerMtok = %f, want 2.50", *meta.Pricing.InputPerMtok) @@ -446,9 +457,11 @@ func TestResolve_ReverseIndexWithProviderModelOverride(t *testing.T) { meta := Resolve(list, "openai", "gpt-4o-2024-08-06") if meta == nil { t.Fatal("expected non-nil metadata via reverse lookup") + return } if meta.Pricing == nil { t.Fatal("expected non-nil pricing") + return } // Should use the provider_model override, not the base model pricing if *meta.Pricing.InputPerMtok != 3.00 { @@ -494,6 +507,7 @@ func TestResolve_ModelAliasUsesProviderOverride(t *testing.T) { meta := Resolve(list, "gemini", "claude-opus-4") if meta == nil { t.Fatal("expected non-nil metadata via model alias") + return } if meta.DisplayName != "Claude 4 Opus" { t.Fatalf("DisplayName = %q, want Claude 4 Opus", meta.DisplayName) diff --git a/internal/observability/metrics_test.go b/internal/observability/metrics_test.go index c5ad4749..954a5d9e 100644 --- a/internal/observability/metrics_test.go +++ b/internal/observability/metrics_test.go @@ -360,6 +360,7 @@ func TestGetMetrics(t *testing.T) { if metrics == nil { t.Fatal("GetMetrics returned nil") + return } if metrics.RequestsTotal == nil { diff --git a/internal/providers/anthropic/anthropic_test.go b/internal/providers/anthropic/anthropic_test.go index 4e616042..d7f1a701 100644 --- a/internal/providers/anthropic/anthropic_test.go +++ b/internal/providers/anthropic/anthropic_test.go @@ -3161,6 +3161,7 @@ func TestBuildAnthropicBatchCreateRequest_NormalizesFullURLResponsesEndpoint(t * } if anthropicReq == nil { t.Fatal("anthropicReq = nil") + return } if len(anthropicReq.Requests) != 1 { t.Fatalf("len(Requests) = %d, want 1", len(anthropicReq.Requests)) diff --git a/internal/providers/anthropic/passthrough_semantics_test.go b/internal/providers/anthropic/passthrough_semantics_test.go index c91ee75d..be92354d 100644 --- a/internal/providers/anthropic/passthrough_semantics_test.go +++ b/internal/providers/anthropic/passthrough_semantics_test.go @@ -40,6 +40,7 @@ func TestPassthroughSemanticEnricher_Enrich(t *testing.T) { got := enricher.Enrich(nil, nil, tt.info) if got == nil { t.Fatal("Enrich() = nil") + return } if got.SemanticOperation != tt.wantOperation { t.Fatalf("SemanticOperation = %q, want %q", got.SemanticOperation, tt.wantOperation) diff --git a/internal/providers/openai/passthrough_semantics_test.go b/internal/providers/openai/passthrough_semantics_test.go index 62dd717d..2d785cff 100644 --- a/internal/providers/openai/passthrough_semantics_test.go +++ b/internal/providers/openai/passthrough_semantics_test.go @@ -46,6 +46,7 @@ func TestPassthroughSemanticEnricher_Enrich(t *testing.T) { got := enricher.Enrich(nil, nil, tt.info) if got == nil { t.Fatal("Enrich() = nil") + return } if got.SemanticOperation != tt.wantOperation { t.Fatalf("SemanticOperation = %q, want %q", got.SemanticOperation, tt.wantOperation) diff --git a/internal/providers/oracle/oracle_test.go b/internal/providers/oracle/oracle_test.go index 94d9b57c..cffa0ac0 100644 --- a/internal/providers/oracle/oracle_test.go +++ b/internal/providers/oracle/oracle_test.go @@ -28,6 +28,7 @@ func TestListModels_FallsBackToConfiguredModelsWhenUpstreamFails(t *testing.T) { } if resp == nil { t.Fatal("expected response, got nil") + return } if len(resp.Data) != 2 { t.Fatalf("len(resp.Data) = %d, want 2", len(resp.Data)) diff --git a/internal/providers/xai/xai_test.go b/internal/providers/xai/xai_test.go index d677f887..b00517ee 100644 --- a/internal/providers/xai/xai_test.go +++ b/internal/providers/xai/xai_test.go @@ -88,6 +88,7 @@ func TestNewWithHTTPClient(t *testing.T) { // Verify provider is non-nil if provider == nil { t.Fatal("provider should not be nil") + return } if provider.client == nil { t.Fatal("provider.client should not be nil")