diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx
index cf019a8a..7cdf0095 100644
--- a/docs/advanced/configuration.mdx
+++ b/docs/advanced/configuration.mdx
@@ -55,6 +55,12 @@ The most common way to configure GOModel. Set any of the variables below to over
| `REDIS_TTL_MODELS` | TTL in seconds for model cache | `86400` (24h) |
| `REDIS_TTL_RESPONSES` | TTL in seconds for response cache | `3600` (1h) |
+
+ See [Cache](/features/cache) for exact-cache behavior, response headers,
+ analytics endpoints, and the note that `user_path` alone does not partition
+ the exact cache.
+
+
#### Storage
Storage is shared by audit logging, usage tracking, and future features like IAM.
diff --git a/docs/docs.json b/docs/docs.json
index 02b58e83..8a202958 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -15,6 +15,10 @@
"group": "Getting Started",
"pages": ["getting-started/quickstart"]
},
+ {
+ "group": "Features",
+ "pages": ["features/cache"]
+ },
{
"group": "Guides",
"pages": [
diff --git a/docs/features/cache.mdx b/docs/features/cache.mdx
new file mode 100644
index 00000000..84b24307
--- /dev/null
+++ b/docs/features/cache.mdx
@@ -0,0 +1,96 @@
+---
+title: "Cache"
+description: "How GOModel response caching works, how to enable it, and what is included in the exact-cache key."
+---
+
+## Overview
+
+GOModel ships with an exact-match response cache for non-streaming requests on:
+
+- `/v1/chat/completions`
+- `/v1/responses`
+- `/v1/embeddings`
+
+When a response is served from the exact cache, GOModel returns:
+
+```http
+X-Cache: HIT (exact)
+```
+
+Semantic caching is planned separately. The exact layer is the cache behavior
+available today.
+
+## Enable the exact cache
+
+Point response caching at Redis:
+
+```yaml
+cache:
+ response:
+ simple:
+ redis:
+ url: redis://localhost:6379
+ ttl: 3600
+```
+
+You can also configure it with environment variables:
+
+- `REDIS_URL`
+- `REDIS_KEY_RESPONSES`
+- `REDIS_TTL_RESPONSES`
+
+## What the exact cache keys on
+
+The exact cache hashes:
+
+- the request path
+- the resolved execution plan context used for execution
+ specifically execution mode, provider type, and resolved model
+- the final request body
+
+This means guardrails and workflows affect cache keys when they change the
+resolved execution plan or the final body sent through execution.
+
+You can bypass caching per request with:
+
+```http
+Cache-Control: no-cache
+```
+
+or:
+
+```http
+Cache-Control: no-store
+```
+
+## `user_path` behavior
+
+`user_path` is not added to the exact-cache key by itself.
+
+That is intentional. If two requests end up with the same path, resolved
+execution plan, and final request body, they can share the same exact-cache
+entry even when they originate from different `user_path` values.
+
+
+ If you need tenant or path-specific cache behavior, use a scoped workflow or
+ otherwise make the final request differ for that scope. `user_path` alone is
+ not an exact-cache partition key.
+
+
+Common patterns:
+
+- disable cache in a scoped workflow
+- use different scoped workflows for different `user_path` values
+- include scope-specific context so the final request body differs
+
+## Cache analytics
+
+When response caching and usage tracking are enabled, the admin API exposes a
+cached-only overview at:
+
+```text
+/admin/api/v1/cache/overview
+```
+
+Cached usage entries are also visible in the regular usage log and summary
+endpoints.
diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx
index 882beb84..0a32e78a 100644
--- a/docs/getting-started/quickstart.mdx
+++ b/docs/getting-started/quickstart.mdx
@@ -79,6 +79,7 @@ Use one of those model IDs in your requests.
## Next Steps
+- Understand response caching: [Cache](/features/cache)
- Configure production settings: [Configuration](/advanced/configuration)
- Add request policies: [Guardrails](/advanced/guardrails)
- Connect OpenClaw: [Using GOModel with OpenClaw](/guides/openclaw)
diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css
index 688cc9d6..0636a569 100644
--- a/internal/admin/dashboard/static/css/dashboard.css
+++ b/internal/admin/dashboard/static/css/dashboard.css
@@ -3137,14 +3137,14 @@ body.conversation-drawer-open {
color: var(--danger);
}
-.auth-key-copy-btn {
+.copy-feedback-btn {
display: inline-flex;
align-items: center;
gap: 6px;
transition: background-color 0.15s, border-color 0.15s, color 0.15s;
}
-.auth-key-copy-btn-copied {
+.copy-feedback-btn-copied {
background: color-mix(in srgb, var(--success) 12%, var(--bg));
border-color: color-mix(in srgb, var(--success) 40%, var(--border));
color: var(--success);
diff --git a/internal/admin/dashboard/static/js/modules/audit-list.js b/internal/admin/dashboard/static/js/modules/audit-list.js
index e32ce9db..2ce2a475 100644
--- a/internal/admin/dashboard/static/js/modules/audit-list.js
+++ b/internal/admin/dashboard/static/js/modules/audit-list.js
@@ -1,5 +1,12 @@
(function(global) {
function dashboardAuditListModule() {
+ const clipboardModuleFactory = typeof global.dashboardClipboardModule === 'function'
+ ? global.dashboardClipboardModule
+ : null;
+ const clipboard = clipboardModuleFactory
+ ? clipboardModuleFactory()
+ : null;
+
return {
_auditQueryStr() {
if (this.customStartDate && this.customEndDate) {
@@ -182,26 +189,27 @@
};
},
- async copyAuditJSON(v, event) {
- if (v == null || v === undefined || v === '') return;
- const button = event && event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
- if (button && !button.dataset.copyLabel) {
- button.dataset.copyLabel = String(button.textContent || 'Copy').trim();
- }
- try {
- const payload = this.formatJSON(v);
- await navigator.clipboard.writeText(payload);
- } catch (e) {
- console.error('Failed to copy audit payload:', e);
- if (button) {
- button.textContent = 'Copy failed';
- button.disabled = true;
- window.setTimeout(() => {
- button.textContent = button.dataset.copyLabel || 'Copy';
- button.disabled = false;
- }, 2000);
+ auditPaneState(pane) {
+ const formatJSON = this.formatJSON.bind(this);
+
+ return {
+ pane,
+ copyState: clipboard
+ ? clipboard.createClipboardButtonState({
+ logPrefix: 'Failed to copy audit payload:'
+ })
+ : {
+ copied: false,
+ error: false,
+ copy() {
+ return Promise.resolve();
+ }
+ },
+
+ copyBody() {
+ return this.copyState.copy(this.pane.copyBody, formatJSON);
}
- }
+ };
}
};
}
diff --git a/internal/admin/dashboard/static/js/modules/audit-list.test.js b/internal/admin/dashboard/static/js/modules/audit-list.test.js
index f36bce50..ca0bd2b8 100644
--- a/internal/admin/dashboard/static/js/modules/audit-list.test.js
+++ b/internal/admin/dashboard/static/js/modules/audit-list.test.js
@@ -5,6 +5,7 @@ const path = require('node:path');
const vm = require('node:vm');
function loadAuditListModuleFactory(overrides = {}) {
+ const clipboardSource = fs.readFileSync(path.join(__dirname, 'clipboard.js'), 'utf8');
const source = fs.readFileSync(path.join(__dirname, 'audit-list.js'), 'utf8');
const window = {
...(overrides.window || {})
@@ -15,6 +16,7 @@ function loadAuditListModuleFactory(overrides = {}) {
window
};
vm.createContext(context);
+ vm.runInContext(clipboardSource, context);
vm.runInContext(source, context);
return context.window.dashboardAuditListModule;
}
@@ -122,3 +124,79 @@ test('fetchAuditLog preserves a successful payload when workflow prefetch fails'
assert.equal(loggedErrors.length, 1);
assert.match(String(loggedErrors[0][0]), /Failed to prefetch audit workflows:/);
});
+
+test('auditPaneState copies the formatted body and resets success feedback', async () => {
+ let resetCallback = null;
+ const writes = [];
+ const module = createAuditListModule({
+ setTimeout(callback) {
+ resetCallback = callback;
+ return 1;
+ },
+ clearTimeout() {},
+ window: {
+ navigator: {
+ clipboard: {
+ writeText(value) {
+ writes.push(value);
+ return Promise.resolve();
+ }
+ }
+ }
+ }
+ });
+
+ const paneState = module.auditPaneState({
+ copyBody: { model: 'gpt-5', stream: false }
+ });
+
+ await paneState.copyBody();
+
+ assert.deepEqual(writes, ['{\n "model": "gpt-5",\n "stream": false\n}']);
+ assert.equal(paneState.copyState.copied, true);
+ assert.equal(paneState.copyState.error, false);
+
+ assert.equal(typeof resetCallback, 'function');
+ resetCallback();
+
+ assert.equal(paneState.copyState.copied, false);
+ assert.equal(paneState.copyState.error, false);
+});
+
+test('auditPaneState marks copy failures and clears the error after reset', async () => {
+ let resetCallback = null;
+ const module = createAuditListModule({
+ console: {
+ error() {}
+ },
+ setTimeout(callback) {
+ resetCallback = callback;
+ return 1;
+ },
+ clearTimeout() {},
+ window: {
+ navigator: {
+ clipboard: {
+ writeText() {
+ return Promise.reject(new Error('denied'));
+ }
+ }
+ }
+ }
+ });
+
+ const paneState = module.auditPaneState({
+ copyBody: { id: 'resp_123' }
+ });
+
+ await paneState.copyBody();
+
+ assert.equal(paneState.copyState.copied, false);
+ assert.equal(paneState.copyState.error, true);
+
+ assert.equal(typeof resetCallback, 'function');
+ resetCallback();
+
+ assert.equal(paneState.copyState.copied, false);
+ assert.equal(paneState.copyState.error, false);
+});
diff --git a/internal/admin/dashboard/static/js/modules/auth-keys.js b/internal/admin/dashboard/static/js/modules/auth-keys.js
index 2d8f03a9..5ee6bcec 100644
--- a/internal/admin/dashboard/static/js/modules/auth-keys.js
+++ b/internal/admin/dashboard/static/js/modules/auth-keys.js
@@ -1,5 +1,12 @@
(function(global) {
function dashboardAuthKeysModule() {
+ const clipboardModuleFactory = typeof global.dashboardClipboardModule === 'function'
+ ? global.dashboardClipboardModule
+ : null;
+ const clipboard = clipboardModuleFactory
+ ? clipboardModuleFactory()
+ : null;
+
return {
authKeys: [],
authKeysAvailable: true,
@@ -10,9 +17,18 @@
authKeyFormSubmitting: false,
authKeyIssuedValue: '',
authKeyDeactivatingID: '',
- authKeyCopied: false,
- authKeyCopyError: false,
- authKeyCopyResetTimer: null,
+ authKeyCopyState: clipboard
+ ? clipboard.createClipboardButtonState({
+ logPrefix: 'Failed to copy auth key:'
+ })
+ : {
+ copied: false,
+ error: false,
+ resetFeedback() {},
+ copy() {
+ return Promise.resolve();
+ }
+ },
authKeyForm: {
name: '',
description: '',
@@ -106,7 +122,7 @@
this.authKeyError = '';
this.authKeyNotice = '';
if (!this.authKeyIssuedValue) {
- this.resetAuthKeyCopyFeedback();
+ this.authKeyCopyState.resetFeedback();
this.authKeyForm = this.defaultAuthKeyForm();
}
},
@@ -117,97 +133,19 @@
}
this.authKeyFormOpen = false;
this.authKeyError = '';
- this.resetAuthKeyCopyFeedback();
+ this.authKeyCopyState.resetFeedback();
if (!this.authKeyFormSubmitting && !this.authKeyIssuedValue) {
this.authKeyForm = this.defaultAuthKeyForm();
}
},
- clearAuthKeyCopyResetTimer() {
- if (this.authKeyCopyResetTimer !== null) {
- clearTimeout(this.authKeyCopyResetTimer);
- this.authKeyCopyResetTimer = null;
- }
- },
-
- scheduleAuthKeyCopyFeedbackReset() {
- this.clearAuthKeyCopyResetTimer();
- this.authKeyCopyResetTimer = setTimeout(() => {
- this.authKeyCopied = false;
- this.authKeyCopyError = false;
- this.authKeyCopyResetTimer = null;
- }, 2000);
- },
-
- resetAuthKeyCopyFeedback() {
- this.clearAuthKeyCopyResetTimer();
- this.authKeyCopied = false;
- this.authKeyCopyError = false;
- },
-
- setAuthKeyCopyFeedback(copied, hasError) {
- this.authKeyCopied = copied;
- this.authKeyCopyError = hasError;
- this.scheduleAuthKeyCopyFeedbackReset();
- },
-
copyAuthKeyValue() {
- const value = String(this.authKeyIssuedValue || '');
- const clipboard = global.navigator && global.navigator.clipboard;
-
- this.resetAuthKeyCopyFeedback();
-
- if (clipboard && typeof clipboard.writeText === 'function') {
- return clipboard.writeText(value).then(() => {
- this.setAuthKeyCopyFeedback(true, false);
- }).catch((error) => {
- console.error('Failed to copy auth key:', error);
- this.setAuthKeyCopyFeedback(false, true);
- });
- }
-
- const doc = global.document;
- if (!doc || !doc.body || typeof doc.createElement !== 'function' || typeof doc.execCommand !== 'function') {
- this.setAuthKeyCopyFeedback(false, true);
- return Promise.resolve();
- }
-
- const textarea = doc.createElement('textarea');
- textarea.value = value;
- textarea.setAttribute('readonly', '');
- textarea.style.position = 'fixed';
- textarea.style.top = '0';
- textarea.style.left = '0';
- textarea.style.opacity = '0';
-
- try {
- doc.body.appendChild(textarea);
- if (typeof textarea.focus === 'function') {
- textarea.focus();
- }
- if (typeof textarea.select === 'function') {
- textarea.select();
- }
- if (typeof textarea.setSelectionRange === 'function') {
- textarea.setSelectionRange(0, textarea.value.length);
- }
- const copied = !!doc.execCommand('copy');
- this.setAuthKeyCopyFeedback(copied, !copied);
- } catch (error) {
- console.error('Failed to copy auth key:', error);
- this.setAuthKeyCopyFeedback(false, true);
- } finally {
- if (textarea.parentNode) {
- textarea.parentNode.removeChild(textarea);
- }
- }
-
- return Promise.resolve();
+ return this.authKeyCopyState.copy(this.authKeyIssuedValue);
},
dismissIssuedKey() {
this.authKeyIssuedValue = '';
- this.resetAuthKeyCopyFeedback();
+ this.authKeyCopyState.resetFeedback();
this.authKeyForm = this.defaultAuthKeyForm();
},
@@ -273,7 +211,7 @@
const issued = await res.json();
this.authKeyIssuedValue = issued.value || '';
this.authKeyFormOpen = true;
- this.resetAuthKeyCopyFeedback();
+ this.authKeyCopyState.resetFeedback();
this.authKeyForm = this.defaultAuthKeyForm();
await this.fetchAuthKeys();
} catch (e) {
diff --git a/internal/admin/dashboard/static/js/modules/auth-keys.test.js b/internal/admin/dashboard/static/js/modules/auth-keys.test.js
index 2ee921a7..2c9827a6 100644
--- a/internal/admin/dashboard/static/js/modules/auth-keys.test.js
+++ b/internal/admin/dashboard/static/js/modules/auth-keys.test.js
@@ -5,6 +5,7 @@ const path = require('node:path');
const vm = require('node:vm');
function loadAuthKeysModuleFactory(overrides = {}) {
+ const clipboardSource = fs.readFileSync(path.join(__dirname, 'clipboard.js'), 'utf8');
const source = fs.readFileSync(path.join(__dirname, 'auth-keys.js'), 'utf8');
const window = {
...(overrides.window || {})
@@ -17,6 +18,7 @@ function loadAuthKeysModuleFactory(overrides = {}) {
window
};
vm.createContext(context);
+ vm.runInContext(clipboardSource, context);
vm.runInContext(source, context);
return context.window.dashboardAuthKeysModule;
}
@@ -162,13 +164,13 @@ test('copyAuthKeyValue uses navigator.clipboard when available and resets feedba
await module.copyAuthKeyValue();
assert.deepEqual(writes, ['sk_gom_test']);
- assert.equal(module.authKeyCopied, true);
- assert.equal(module.authKeyCopyError, false);
+ assert.equal(module.authKeyCopyState.copied, true);
+ assert.equal(module.authKeyCopyState.error, false);
timers.runAll();
- assert.equal(module.authKeyCopied, false);
- assert.equal(module.authKeyCopyError, false);
+ assert.equal(module.authKeyCopyState.copied, false);
+ assert.equal(module.authKeyCopyState.error, false);
});
test('copyAuthKeyValue sets an error flag when navigator.clipboard rejects', async () => {
@@ -194,13 +196,13 @@ test('copyAuthKeyValue sets an error flag when navigator.clipboard rejects', asy
await module.copyAuthKeyValue();
- assert.equal(module.authKeyCopied, false);
- assert.equal(module.authKeyCopyError, true);
+ assert.equal(module.authKeyCopyState.copied, false);
+ assert.equal(module.authKeyCopyState.error, true);
timers.runAll();
- assert.equal(module.authKeyCopied, false);
- assert.equal(module.authKeyCopyError, false);
+ assert.equal(module.authKeyCopyState.copied, false);
+ assert.equal(module.authKeyCopyState.error, false);
});
test('copyAuthKeyValue falls back to document.execCommand when clipboard API is unavailable', async () => {
@@ -250,8 +252,8 @@ test('copyAuthKeyValue falls back to document.execCommand when clipboard API is
assert.equal(appended.length, 1);
assert.equal(removed.length, 1);
assert.equal(appended[0].value, 'sk_gom_test');
- assert.equal(module.authKeyCopied, true);
- assert.equal(module.authKeyCopyError, false);
+ assert.equal(module.authKeyCopyState.copied, true);
+ assert.equal(module.authKeyCopyState.error, false);
});
test('fetchAuthKeys preserves existing rows and surfaces non-auth HTTP errors', async () => {
diff --git a/internal/admin/dashboard/static/js/modules/clipboard.js b/internal/admin/dashboard/static/js/modules/clipboard.js
new file mode 100644
index 00000000..003ee25e
--- /dev/null
+++ b/internal/admin/dashboard/static/js/modules/clipboard.js
@@ -0,0 +1,131 @@
+(function(global) {
+ function fallbackClipboardModule(scope) {
+ function getScheduler(name, fallback) {
+ if (scope && typeof scope[name] === 'function') {
+ return scope[name].bind(scope);
+ }
+ if (typeof fallback === 'function') {
+ return fallback;
+ }
+ return null;
+ }
+
+ function writeTextToClipboard(value) {
+ const payload = String(value == null ? '' : value);
+ const clipboard = scope.navigator && scope.navigator.clipboard;
+
+ if (clipboard && typeof clipboard.writeText === 'function') {
+ return clipboard.writeText(payload);
+ }
+
+ const doc = scope.document;
+ if (!doc || !doc.body || typeof doc.createElement !== 'function' || typeof doc.execCommand !== 'function') {
+ return Promise.reject(new Error('Clipboard API unavailable'));
+ }
+
+ const textarea = doc.createElement('textarea');
+ textarea.value = payload;
+ textarea.setAttribute('readonly', '');
+ textarea.style.position = 'fixed';
+ textarea.style.top = '0';
+ textarea.style.left = '0';
+ textarea.style.opacity = '0';
+
+ try {
+ doc.body.appendChild(textarea);
+ if (typeof textarea.focus === 'function') {
+ textarea.focus();
+ }
+ if (typeof textarea.select === 'function') {
+ textarea.select();
+ }
+ if (typeof textarea.setSelectionRange === 'function') {
+ textarea.setSelectionRange(0, textarea.value.length);
+ }
+
+ if (!doc.execCommand('copy')) {
+ throw new Error('execCommand copy returned false');
+ }
+ } finally {
+ if (textarea.parentNode) {
+ textarea.parentNode.removeChild(textarea);
+ }
+ }
+
+ return Promise.resolve();
+ }
+
+ function createClipboardButtonState(options = {}) {
+ const setTimer = getScheduler('setTimeout', typeof setTimeout === 'function' ? setTimeout : null);
+ const clearTimer = getScheduler('clearTimeout', typeof clearTimeout === 'function' ? clearTimeout : null);
+ const resetDelayMs = Number.isFinite(options.resetDelayMs) ? options.resetDelayMs : 2000;
+
+ return {
+ copied: false,
+ error: false,
+ resetTimer: null,
+
+ clearResetTimer() {
+ if (this.resetTimer !== null && clearTimer) {
+ clearTimer(this.resetTimer);
+ }
+ this.resetTimer = null;
+ },
+
+ scheduleReset() {
+ this.clearResetTimer();
+ if (!setTimer) {
+ return;
+ }
+ this.resetTimer = setTimer(() => {
+ this.copied = false;
+ this.error = false;
+ this.resetTimer = null;
+ }, resetDelayMs);
+ },
+
+ resetFeedback() {
+ this.clearResetTimer();
+ this.copied = false;
+ this.error = false;
+ },
+
+ setFeedback(copied, error) {
+ this.copied = copied;
+ this.error = error;
+ this.scheduleReset();
+ },
+
+ async copy(value, formatValue) {
+ if (value == null || value === undefined || value === '') {
+ return;
+ }
+
+ this.resetFeedback();
+
+ try {
+ const payload = typeof formatValue === 'function'
+ ? formatValue(value)
+ : String(value);
+ await writeTextToClipboard(payload);
+ this.setFeedback(true, false);
+ } catch (error) {
+ console.error(options.logPrefix || 'Failed to copy text:', error);
+ this.setFeedback(false, true);
+ }
+ }
+ };
+ }
+
+ return {
+ writeTextToClipboard,
+ createClipboardButtonState
+ };
+ }
+
+ function dashboardClipboardModule() {
+ return fallbackClipboardModule(global);
+ }
+
+ global.dashboardClipboardModule = dashboardClipboardModule;
+})(window);
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 94efa442..7b3cfd67 100644
--- a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js
+++ b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js
@@ -70,6 +70,10 @@ test('dashboard layout pins Chart.js to 4.5.0', () => {
template,
/
+
diff --git a/internal/aliases/batch_preparer_test.go b/internal/aliases/batch_preparer_test.go
index 81ad6c71..ed2dd1eb 100644
--- a/internal/aliases/batch_preparer_test.go
+++ b/internal/aliases/batch_preparer_test.go
@@ -93,3 +93,40 @@ func TestBatchPreparerRejectsAliasResolvedToDifferentProvider(t *testing.T) {
t.Fatalf("len(fileCreates) = %d, want 0", len(inner.fileCreates))
}
}
+
+func TestBatchPreparerRejectsUnsupportedExplicitProviderSelector(t *testing.T) {
+ catalog := newTestCatalog()
+ catalog.add("anthropic/claude-3-7-sonnet", "anthropic", core.Model{ID: "claude-3-7-sonnet", Object: "model"})
+
+ service, err := NewService(newMemoryStore(Alias{Name: "smart", TargetModel: "claude-3-7-sonnet", TargetProvider: "anthropic", Enabled: true}), catalog)
+ if err != nil {
+ t.Fatalf("NewService() error = %v", err)
+ }
+ if err := service.Refresh(context.Background()); err != nil {
+ t.Fatalf("Refresh() error = %v", err)
+ }
+
+ inner := newProviderMock()
+ inner.supported["anthropic/claude-3-7-sonnet"] = true
+ inner.providerType["anthropic/claude-3-7-sonnet"] = "anthropic"
+ inner.fileContent = &core.FileContentResponse{
+ ID: "file_source",
+ Filename: "batch.jsonl",
+ Data: []byte("{\"custom_id\":\"1\",\"method\":\"POST\",\"url\":\"/v1/chat/completions\",\"body\":{\"model\":\"smart\",\"provider\":\"openai\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}}\n"),
+ }
+
+ preparer := NewBatchPreparer(inner, service)
+ _, err = preparer.PrepareBatchRequest(context.Background(), "openai", &core.BatchRequest{
+ InputFileID: "file_source",
+ Endpoint: "/v1/chat/completions",
+ })
+ if err == nil {
+ t.Fatal("PrepareBatchRequest() error = nil, want unsupported model error")
+ }
+ if !strings.Contains(err.Error(), `unsupported model: openai/smart`) {
+ t.Fatalf("PrepareBatchRequest() error = %v, want unsupported model: openai/smart", err)
+ }
+ if len(inner.fileCreates) != 0 {
+ t.Fatalf("len(fileCreates) = %d, want 0", len(inner.fileCreates))
+ }
+}
diff --git a/internal/aliases/service.go b/internal/aliases/service.go
index d0d6b0ed..44367db9 100644
--- a/internal/aliases/service.go
+++ b/internal/aliases/service.go
@@ -122,16 +122,18 @@ func (s *Service) Resolve(model, provider string) (Resolution, bool, error) {
}
func (s *Service) resolveRequested(requested core.RequestedModelSelector) (Resolution, bool, error) {
- if !requested.ExplicitProvider {
- if resolution, ok := s.resolveAlias(requested.Model); ok {
- return resolution, true, nil
- }
- }
-
selector, err := requested.Normalize()
if err != nil {
return Resolution{}, false, err
}
+
+ if requested.ExplicitProvider {
+ return Resolution{Requested: selector, Resolved: selector}, false, nil
+ }
+
+ if resolution, ok := s.resolveAlias(requested.Model); ok {
+ return resolution, true, nil
+ }
return Resolution{Requested: selector, Resolved: selector}, false, nil
}
diff --git a/internal/aliases/service_test.go b/internal/aliases/service_test.go
index 514833bf..89fbec94 100644
--- a/internal/aliases/service_test.go
+++ b/internal/aliases/service_test.go
@@ -235,6 +235,35 @@ func TestServiceResolveAliasWithExplicitProviderAndSlashModel(t *testing.T) {
}
}
+func TestServiceResolveAliasWithExplicitProviderPreservesRequestedSelector(t *testing.T) {
+ catalog := newTestCatalog()
+ catalog.add("anthropic/claude-3-7-sonnet", "anthropic", core.Model{ID: "claude-3-7-sonnet", Object: "model"})
+
+ service, err := NewService(newMemoryStore(Alias{
+ Name: "smart",
+ TargetModel: "claude-3-7-sonnet",
+ TargetProvider: "anthropic",
+ Enabled: true,
+ }), catalog)
+ if err != nil {
+ t.Fatalf("NewService() error = %v", err)
+ }
+ if err := service.Refresh(context.Background()); err != nil {
+ t.Fatalf("Refresh() error = %v", err)
+ }
+
+ selector, changed, err := service.ResolveModel(core.NewRequestedModelSelector("smart", "openai"))
+ if err != nil {
+ t.Fatalf("ResolveModel() error = %v", err)
+ }
+ if changed {
+ t.Fatal("ResolveModel() changed = true, want false")
+ }
+ if got := selector.QualifiedModel(); got != "openai/smart" {
+ t.Fatalf("resolved selector = %q, want openai/smart", got)
+ }
+}
+
func TestServiceUpsertRejectsQualifiedAliasChainsAndSelfTargets(t *testing.T) {
catalog := newTestCatalog()
catalog.add("gpt-4o", "openai", core.Model{ID: "gpt-4o", Object: "model"})
diff --git a/internal/providers/openai/openai.go b/internal/providers/openai/openai.go
index 4033a5ee..bae594ef 100644
--- a/internal/providers/openai/openai.go
+++ b/internal/providers/openai/openai.go
@@ -89,18 +89,29 @@ func isOSeriesModel(model string) bool {
return len(m) >= 2 && m[0] == 'o' && m[1] >= '0' && m[1] <= '9'
}
-// adaptForOSeries rewrites a ChatRequest body for OpenAI o-series models,
-// mapping max_tokens -> max_completion_tokens and dropping temperature while
-// preserving all unknown top-level JSON fields.
-func adaptForOSeries(req *core.ChatRequest) (any, error) {
+func isGPT5Model(model string) bool {
+ m := strings.ToLower(strings.TrimSpace(model))
+ return m == "gpt-5" || strings.HasPrefix(m, "gpt-5-")
+}
+
+// isReasoningChatModel reports whether the model follows OpenAI's reasoning
+// chat parameter rules for max_completion_tokens and temperature handling.
+func isReasoningChatModel(model string) bool {
+ return isOSeriesModel(model) || isGPT5Model(model)
+}
+
+// adaptForReasoningChat rewrites a ChatRequest body for OpenAI reasoning chat
+// models, mapping max_tokens -> max_completion_tokens and dropping temperature
+// while preserving all unknown top-level JSON fields.
+func adaptForReasoningChat(req *core.ChatRequest) (any, error) {
body, err := json.Marshal(req)
if err != nil {
- return nil, core.NewInvalidRequestError("failed to marshal o-series request: "+err.Error(), err)
+ return nil, core.NewInvalidRequestError("failed to marshal reasoning request: "+err.Error(), err)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
- return nil, core.NewInvalidRequestError("failed to decode o-series request payload: "+err.Error(), err)
+ return nil, core.NewInvalidRequestError("failed to decode reasoning request payload: "+err.Error(), err)
}
if maxTokens, ok := raw["max_tokens"]; ok {
raw["max_completion_tokens"] = maxTokens
@@ -113,8 +124,8 @@ func adaptForOSeries(req *core.ChatRequest) (any, error) {
// chatRequestBody returns the appropriate request body for the model.
// Reasoning models get parameter adaptation; others pass through as-is.
func chatRequestBody(req *core.ChatRequest) (any, error) {
- if isOSeriesModel(req.Model) {
- return adaptForOSeries(req)
+ if isReasoningChatModel(req.Model) {
+ return adaptForReasoningChat(req)
}
return req, nil
}
diff --git a/internal/providers/openai/openai_test.go b/internal/providers/openai/openai_test.go
index 4df6d65c..fa0917c6 100644
--- a/internal/providers/openai/openai_test.go
+++ b/internal/providers/openai/openai_test.go
@@ -1472,6 +1472,31 @@ func TestIsOSeriesModel(t *testing.T) {
}
}
+func TestIsReasoningChatModel(t *testing.T) {
+ tests := []struct {
+ model string
+ expected bool
+ }{
+ {"o3-mini", true},
+ {"o4-mini", true},
+ {"gpt-5", true},
+ {"gpt-5-mini", true},
+ {"gpt-5-nano", true},
+ {"gpt-5-chat-latest", true},
+ {"gpt-4o", false},
+ {"gpt-4.1", false},
+ {"claude-sonnet-4-6", false},
+ {"", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.model, func(t *testing.T) {
+ if got := isReasoningChatModel(tt.model); got != tt.expected {
+ t.Errorf("isReasoningChatModel(%q) = %v, want %v", tt.model, got, tt.expected)
+ }
+ })
+ }
+}
+
func TestChatCompletion_ReasoningModel_AdaptsParameters(t *testing.T) {
maxTokens := 1000
@@ -1536,6 +1561,67 @@ func TestChatCompletion_ReasoningModel_AdaptsParameters(t *testing.T) {
}
}
+func TestChatCompletion_GPT5Model_AdaptsParameters(t *testing.T) {
+ maxTokens := 1000
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatalf("failed to read request body: %v", err)
+ }
+
+ var raw map[string]any
+ if err := json.Unmarshal(body, &raw); err != nil {
+ t.Fatalf("failed to unmarshal request: %v", err)
+ }
+
+ if _, ok := raw["max_tokens"]; ok {
+ t.Error("gpt-5 request should not contain max_tokens")
+ }
+
+ mct, ok := raw["max_completion_tokens"]
+ if !ok {
+ t.Fatal("gpt-5 request should contain max_completion_tokens")
+ }
+ if int(mct.(float64)) != maxTokens {
+ t.Errorf("max_completion_tokens = %v, want %d", mct, maxTokens)
+ }
+
+ if _, ok := raw["temperature"]; ok {
+ t.Error("gpt-5 request should not contain temperature")
+ }
+
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{
+ "id": "chatcmpl-gpt5",
+ "object": "chat.completion",
+ "model": "gpt-5-mini",
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": "Hi"}, "finish_reason": "stop"}],
+ "usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15}
+ }`))
+ }))
+ defer server.Close()
+
+ provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{})
+ provider.SetBaseURL(server.URL)
+
+ temp := 0.7
+ req := &core.ChatRequest{
+ Model: "gpt-5-mini",
+ Messages: []core.Message{{Role: "user", Content: "Hello"}},
+ MaxTokens: &maxTokens,
+ Temperature: &temp,
+ }
+
+ resp, err := provider.ChatCompletion(context.Background(), req)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if resp.Model != "gpt-5-mini" {
+ t.Errorf("Model = %q, want %q", resp.Model, "gpt-5-mini")
+ }
+}
+
func TestChatCompletion_NonReasoningModel_PassesMaxTokens(t *testing.T) {
maxTokens := 1000
@@ -1746,6 +1832,70 @@ data: [DONE]
}
}
+func TestStreamChatCompletion_GPT5Model_AdaptsParameters(t *testing.T) {
+ maxTokens := 2000
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatalf("failed to read request body: %v", err)
+ }
+
+ var raw map[string]any
+ if err := json.Unmarshal(body, &raw); err != nil {
+ t.Fatalf("failed to unmarshal request: %v", err)
+ }
+
+ if _, ok := raw["max_tokens"]; ok {
+ t.Error("streaming gpt-5 request should not contain max_tokens")
+ }
+ if _, ok := raw["temperature"]; ok {
+ t.Error("streaming gpt-5 request should not contain temperature")
+ }
+ mct, ok := raw["max_completion_tokens"]
+ if !ok {
+ t.Fatal("streaming gpt-5 request should contain max_completion_tokens")
+ }
+ if int(mct.(float64)) != maxTokens {
+ t.Errorf("max_completion_tokens = %v, want %d", mct, maxTokens)
+ }
+
+ if stream, ok := raw["stream"].(bool); !ok || !stream {
+ t.Error("stream should be true")
+ }
+
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`data: {"id":"chatcmpl-gpt5","object":"chat.completion.chunk","model":"gpt-5-nano","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}
+
+data: [DONE]
+`))
+ }))
+ defer server.Close()
+
+ provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{})
+ provider.SetBaseURL(server.URL)
+
+ req := &core.ChatRequest{
+ Model: "gpt-5-nano",
+ Messages: []core.Message{{Role: "user", Content: "Hello"}},
+ MaxTokens: &maxTokens,
+ }
+
+ body, err := provider.StreamChatCompletion(context.Background(), req)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ defer func() { _ = body.Close() }()
+
+ respBody, err := io.ReadAll(body)
+ if err != nil {
+ t.Fatalf("failed to read response body: %v", err)
+ }
+ if !strings.Contains(string(respBody), "gpt-5-nano") {
+ t.Error("response should contain gpt-5-nano model")
+ }
+}
+
func TestChatCompletion_ReasoningModel_PreservesToolConfiguration(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go
index a824d669..7497b96a 100644
--- a/internal/server/handlers_test.go
+++ b/internal/server/handlers_test.go
@@ -3435,6 +3435,59 @@ func TestBatches_InputFileRewritesAliasesAndPersistsBatchPreparation(t *testing.
require.Equal(t, "file_rewritten", stored.RewrittenInputFileID)
}
+func TestBatches_InputFileRejectsUnsupportedExplicitProviderSelector(t *testing.T) {
+ store := newAliasesTestStore(aliases.Alias{Name: "smart", TargetModel: "claude-3-7-sonnet", TargetProvider: "anthropic", Enabled: true})
+ catalog := &aliasesTestCatalog{
+ supported: map[string]bool{
+ "anthropic/claude-3-7-sonnet": true,
+ },
+ providerTypes: map[string]string{
+ "anthropic/claude-3-7-sonnet": "anthropic",
+ },
+ models: map[string]core.Model{
+ "anthropic/claude-3-7-sonnet": {ID: "claude-3-7-sonnet", Object: "model"},
+ },
+ }
+ service, err := aliases.NewService(store, catalog)
+ require.NoError(t, err)
+ require.NoError(t, service.Refresh(context.Background()))
+
+ mock := &mockProvider{
+ supportedModels: []string{"claude-3-7-sonnet"},
+ providerTypes: map[string]string{
+ "anthropic/claude-3-7-sonnet": "anthropic",
+ },
+ fileContentResponse: &core.FileContentResponse{
+ ID: "file_source",
+ Filename: "batch.jsonl",
+ Data: []byte("{\"custom_id\":\"chat-1\",\"method\":\"POST\",\"url\":\"/v1/chat/completions\",\"body\":{\"model\":\"smart\",\"provider\":\"openai\",\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}}\n"),
+ },
+ }
+ aliasBatchPreparer := aliases.NewBatchPreparer(mock, service)
+
+ e := echo.New()
+ handler := NewHandler(mock, nil, nil, nil)
+ handler.modelResolver = service
+ handler.batchRequestPreparer = aliasBatchPreparer
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/batches", strings.NewReader(`{
+ "input_file_id":"file_source",
+ "endpoint":"/v1/chat/completions",
+ "completion_window":"24h",
+ "metadata":{"provider":"openai"}
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ c := e.NewContext(req, rec)
+
+ err = handler.Batches(c)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+ require.Contains(t, rec.Body.String(), "unsupported model: openai/smart")
+ require.Nil(t, mock.capturedBatchReq)
+ require.Empty(t, mock.capturedFileCreateReqs)
+}
+
func TestBatches_RollsBackPreparedInputAndUpstreamBatchWhenStoreCreateFails(t *testing.T) {
inner := &mockProvider{
batchCreateResponse: &core.BatchResponse{
diff --git a/tests/e2e/release-e2e-scenarios.md b/tests/e2e/release-e2e-scenarios.md
index f5730d24..33c214ee 100644
--- a/tests/e2e/release-e2e-scenarios.md
+++ b/tests/e2e/release-e2e-scenarios.md
@@ -1,6 +1,6 @@
# Release E2E Curl Matrix
-This file contains 62 end-to-end curl scenarios for release validation.
+This file contains 79 end-to-end curl scenarios for release validation.
These scenarios are prepared for execution across these local gateways:
- `http://localhost:18080` - SQLite-backed main test gateway
@@ -26,9 +26,55 @@ export BATCH_FILE=/tmp/qa-openai-batch.jsonl
export UPLOAD_FILE=/tmp/qa-upload.txt
```
+## Auth-enabled runtime environment
+
+These scenarios target the auth-enabled live gateway on `http://localhost:8080`
+and cover the newer workflows, managed API keys, and cache analytics features.
+
+```bash
+set -euo pipefail
+if [ ! -r .env ]; then
+ echo "error: .env is missing or unreadable" >&2
+ exit 1
+fi
+
+set -a
+source .env
+set +a
+
+export AUTH_BASE_URL=http://localhost:8080
+export ADMIN_AUTH_HEADER="Authorization: Bearer $GOMODEL_MASTER_KEY"
+
+export QA_SUFFIX="${QA_SUFFIX:-$(date +%s)}"
+export QA_AUTH_KEY_NAME="qa-release-auth-key-$QA_SUFFIX"
+export QA_WORKFLOW_NAME="qa-release-workflow-$QA_SUFFIX"
+export QA_USER_PATH="/team/release/e2e/$QA_SUFFIX"
+export QA_CACHE_USER_PATH="/team/cache/e2e/$QA_SUFFIX"
+
+export QA_AUTH_KEY_JSON="/tmp/qa-release-auth-key-$QA_SUFFIX.json"
+export QA_AUTH_KEY_VALUE_FILE="/tmp/qa-release-auth-key-$QA_SUFFIX.token"
+export QA_WORKFLOW_JSON="/tmp/qa-release-workflow-$QA_SUFFIX.json"
+export QA_WORKFLOW_ID_FILE="/tmp/qa-release-workflow-$QA_SUFFIX.id"
+
+export QA_AUTH_REQ1="qa-auth-cacheoff-$QA_SUFFIX-1"
+export QA_AUTH_REQ2="qa-auth-cacheoff-$QA_SUFFIX-2"
+export QA_CACHE_REQ1="qa-cache-exact-$QA_SUFFIX-1"
+export QA_CACHE_REQ2="qa-cache-exact-$QA_SUFFIX-2"
+export QA_DEACTIVATED_REQ="qa-auth-deactivated-$QA_SUFFIX"
+export QA_CACHE_REPLY="QA_CACHE_EXACT_OK_$QA_SUFFIX"
+
+cleanup_release_auth_artifacts() {
+ rm -f "$QA_AUTH_KEY_JSON" "$QA_AUTH_KEY_VALUE_FILE" "$QA_WORKFLOW_JSON" "$QA_WORKFLOW_ID_FILE"
+}
+
+cleanup_release_auth_artifacts
+trap 'cleanup_release_auth_artifacts' EXIT
+```
+
## 1. Infra, discovery, observability
### S01 Health endpoint
+
Checks basic liveness on the main SQLite-backed gateway.
```bash
@@ -36,6 +82,7 @@ curl -sS "$BASE_URL/health"
```
### S02 Metrics endpoint
+
Checks that Prometheus metrics are exposed.
```bash
@@ -43,6 +90,7 @@ curl -sS "$BASE_URL/metrics" | sed -n '1,20p'
```
### S03 Public models list
+
Checks `/v1/models` and prints a small sample.
```bash
@@ -51,6 +99,7 @@ curl -sS "$BASE_URL/v1/models" \
```
### S04 Admin model inventory
+
Checks `/admin/api/v1/models`.
```bash
@@ -58,6 +107,7 @@ curl -sS "$BASE_URL/admin/api/v1/models" | jq '.[0:5]'
```
### S05 Admin model categories
+
Checks `/admin/api/v1/models/categories`.
```bash
@@ -65,6 +115,7 @@ curl -sS "$BASE_URL/admin/api/v1/models/categories" | jq '.'
```
### S06 Usage summary endpoint
+
Reads aggregate usage summary.
```bash
@@ -72,6 +123,7 @@ curl -sS "$BASE_URL/admin/api/v1/usage/summary" | jq '.'
```
### S07 Usage daily endpoint
+
Reads daily usage rollup.
```bash
@@ -79,6 +131,7 @@ curl -sS "$BASE_URL/admin/api/v1/usage/daily?days=7" | jq '.'
```
### S08 Usage by model endpoint
+
Reads per-model usage totals.
```bash
@@ -86,6 +139,7 @@ curl -sS "$BASE_URL/admin/api/v1/usage/models?limit=10" | jq '.'
```
### S09 Filtered usage log
+
Reads recent usage entries for a specific model.
```bash
@@ -94,6 +148,7 @@ curl -sS "$BASE_URL/admin/api/v1/usage/log?model=gpt-4.1-nano-2025-04-14&limit=5
```
### S10 Audit log endpoint
+
Reads recent audit entries.
```bash
@@ -102,6 +157,7 @@ curl -sS "$BASE_URL/admin/api/v1/audit/log?limit=5" \
```
### S11 Audit conversation endpoint
+
Reads a conversation thread anchored to the newest audit entry.
```bash
@@ -111,6 +167,7 @@ curl -sS "$BASE_URL/admin/api/v1/audit/conversation?log_id=$AUDIT_ID&limit=5" \
```
### S12 Alias list endpoint
+
Reads current aliases.
```bash
@@ -120,6 +177,7 @@ curl -sS "$BASE_URL/admin/api/v1/aliases" | jq '.'
## 2. Alias administration
### S13 Create OpenAI alias
+
Creates an alias pointing to the newest cheap OpenAI model.
```bash
@@ -130,6 +188,7 @@ curl -sS -X PUT "$BASE_URL/admin/api/v1/aliases/qa-gpt-latest" \
```
### S14 Create Anthropic alias
+
Creates an alias pointing to `claude-sonnet-4-6`.
```bash
@@ -140,6 +199,7 @@ curl -sS -X PUT "$BASE_URL/admin/api/v1/aliases/qa-sonnet-thinking" \
```
### S15 Verify aliases are exposed in `/v1/models`
+
Checks that aliases are discoverable through the public model list.
```bash
@@ -150,6 +210,7 @@ curl -sS "$BASE_URL/v1/models" \
## 3. Chat completions
### S16 OpenAI non-streaming chat
+
Basic OpenAI-compatible chat completion.
```bash
@@ -160,6 +221,7 @@ curl -sS "$BASE_URL/v1/chat/completions" \
```
### S17 OpenAI streaming chat
+
Checks SSE chat streaming and final usage chunk.
```bash
@@ -170,6 +232,7 @@ curl -sS --no-buffer "$BASE_URL/v1/chat/completions" \
```
### S18 Older OpenAI model
+
Regression probe against `gpt-3.5-turbo`.
```bash
@@ -180,6 +243,7 @@ curl -sS "$BASE_URL/v1/chat/completions" \
```
### S19 Anthropic Sonnet 4.6 with reasoning
+
Checks extended-thinking compatible request flow through the chat endpoint.
```bash
@@ -190,6 +254,7 @@ curl -sS "$BASE_URL/v1/chat/completions" \
```
### S20 Gemini chat
+
Checks translated chat on Gemini.
```bash
@@ -200,6 +265,7 @@ curl -sS "$BASE_URL/v1/chat/completions" \
```
### S21 Groq chat
+
Checks translated chat on Groq.
```bash
@@ -210,6 +276,7 @@ curl -sS "$BASE_URL/v1/chat/completions" \
```
### S22 xAI chat
+
Checks translated chat on xAI and reasoning-token accounting.
```bash
@@ -220,6 +287,7 @@ curl -sS "$BASE_URL/v1/chat/completions" \
```
### S23 Multimodal chat with image URL
+
Checks multimodal chat completion with image input.
```bash
@@ -230,6 +298,7 @@ curl -sS "$BASE_URL/v1/chat/completions" \
```
### S24 Chat through OpenAI alias
+
Checks alias resolution for OpenAI models.
```bash
@@ -240,6 +309,7 @@ curl -sS "$BASE_URL/v1/chat/completions" \
```
### S25 Chat through Anthropic alias
+
Checks alias resolution for Anthropic models plus reasoning.
```bash
@@ -250,6 +320,7 @@ curl -sS "$BASE_URL/v1/chat/completions" \
```
### S26 Latest GPT reasoning on chat (negative)
+
Reproduces the current gap for `reasoning` on `gpt-5-nano` via chat completions.
```bash
@@ -261,6 +332,7 @@ curl -sS -i "$BASE_URL/v1/chat/completions" \
## 4. Responses API
### S27 Non-streaming responses request
+
Checks basic `/v1/responses`.
```bash
@@ -271,6 +343,7 @@ curl -sS "$BASE_URL/v1/responses" \
```
### S28 Streaming responses request
+
Checks SSE responses streaming.
```bash
@@ -281,6 +354,7 @@ curl -sS --no-buffer "$BASE_URL/v1/responses" \
```
### S29 Latest GPT reasoning via responses
+
Checks the preferred latest-GPT reasoning path.
```bash
@@ -291,6 +365,7 @@ curl -sS "$BASE_URL/v1/responses" \
```
### S30 Multimodal responses request
+
Checks multimodal input through the Responses API.
```bash
@@ -301,6 +376,7 @@ curl -sS "$BASE_URL/v1/responses" \
```
### S31 Responses through OpenAI alias
+
Checks alias resolution on `/v1/responses`.
```bash
@@ -313,6 +389,7 @@ curl -sS "$BASE_URL/v1/responses" \
## 5. Embeddings
### S32 OpenAI embeddings, single input
+
Checks single-item embedding generation.
```bash
@@ -323,6 +400,7 @@ curl -sS "$BASE_URL/v1/embeddings" \
```
### S33 OpenAI embeddings, batch input
+
Checks multi-item embedding generation.
```bash
@@ -333,6 +411,7 @@ curl -sS "$BASE_URL/v1/embeddings" \
```
### S34 Gemini embeddings
+
Checks embeddings on Gemini.
```bash
@@ -345,6 +424,7 @@ curl -sS "$BASE_URL/v1/embeddings" \
## 6. Files
### S35 Upload batch input file to OpenAI
+
Uploads the shared batch fixture.
```bash
@@ -355,6 +435,7 @@ curl -sS "$BASE_URL/v1/files?provider=openai" \
```
### S36 List OpenAI batch files
+
Lists uploaded batch files.
```bash
@@ -363,6 +444,7 @@ curl -sS "$BASE_URL/v1/files?provider=openai&purpose=batch&limit=5" \
```
### S37 Get uploaded batch file metadata
+
Fetches metadata for the newest batch file.
```bash
@@ -371,6 +453,7 @@ curl -sS "$BASE_URL/v1/files/$FILE_ID?provider=openai" | jq '.'
```
### S38 Get uploaded batch file content
+
Fetches raw content for the newest batch file.
```bash
@@ -379,6 +462,7 @@ curl -sS "$BASE_URL/v1/files/$FILE_ID/content?provider=openai"
```
### S39 Upload assistants file to OpenAI
+
Uploads a small text file for create/delete lifecycle testing.
```bash
@@ -389,6 +473,7 @@ curl -sS "$BASE_URL/v1/files?provider=openai" \
```
### S40 Delete assistants file
+
Deletes the newest assistants-purpose file.
```bash
@@ -399,6 +484,7 @@ curl -sS -X DELETE "$BASE_URL/v1/files/$FILE_ID?provider=openai" | jq '.'
## 7. Native batches
### S41 File batch create without `metadata.provider` (negative)
+
Reproduces the current compatibility gap for file-based native batches.
```bash
@@ -410,6 +496,7 @@ curl -sS "$BASE_URL/v1/batches" \
```
### S42 File batch create with `metadata.provider`
+
Creates an OpenAI native batch successfully.
```bash
@@ -421,6 +508,7 @@ curl -sS "$BASE_URL/v1/batches" \
```
### S43 List batches
+
Lists stored batches.
```bash
@@ -429,6 +517,7 @@ curl -sS "$BASE_URL/v1/batches?limit=5" \
```
### S44 Get stored OpenAI batch
+
Reads the newest OpenAI batch.
```bash
@@ -437,6 +526,7 @@ curl -sS "$BASE_URL/v1/batches/$BATCH_ID" | jq '.'
```
### S45 Get OpenAI batch results before ready (negative)
+
Checks current pending-results behavior.
```bash
@@ -445,6 +535,7 @@ curl -sS -i "$BASE_URL/v1/batches/$BATCH_ID/results"
```
### S46 Cancel OpenAI batch
+
Cancels the newest OpenAI batch.
```bash
@@ -453,6 +544,7 @@ curl -sS -X POST "$BASE_URL/v1/batches/$BATCH_ID/cancel" | jq '.'
```
### S47 Create inline Anthropic batch
+
Checks provider-native inline batch support.
```bash
@@ -463,6 +555,7 @@ curl -sS "$BASE_URL/v1/batches" \
```
### S48 Mixed-provider alias batch rejection (negative)
+
Checks that a batch provider mismatch is rejected before upstream submission.
```bash
@@ -478,6 +571,7 @@ curl -sS -i "$BASE_URL/v1/batches" \
## 8. Provider passthrough
### S49 OpenAI passthrough with `/v1`
+
Checks raw passthrough to OpenAI.
```bash
@@ -488,6 +582,7 @@ curl -sS -i "$BASE_URL/p/openai/v1/chat/completions" \
```
### S50 OpenAI passthrough without `/v1`
+
Checks endpoint normalization for passthrough.
```bash
@@ -499,6 +594,7 @@ curl -sS "$BASE_URL/p/openai/chat/completions" \
```
### S51 Anthropic passthrough
+
Checks raw passthrough to Anthropic messages API.
```bash
@@ -509,6 +605,7 @@ curl -sS -i "$BASE_URL/p/anthropic/v1/messages" \
```
### S52 Passthrough normalized error
+
Checks that passthrough upstream errors are normalized to gateway error shape.
```bash
@@ -518,6 +615,7 @@ curl -sS -i "$BASE_URL/p/openai/v1/chat/completions" \
```
### S53 Passthrough streaming SSE
+
Checks raw streaming passthrough behavior.
```bash
@@ -531,6 +629,7 @@ curl -sS --no-buffer "$BASE_URL/p/openai/v1/chat/completions" \
## 9. Storage backends and guardrails
### S54 PostgreSQL smoke
+
Checks health, one model request, then admin usage/audit after the flush interval.
```bash
@@ -546,6 +645,7 @@ curl -sS "$PG_BASE_URL/admin/api/v1/audit/log?limit=3" \
```
### S55 MongoDB smoke
+
Checks health, one model request, then admin audit/usage on MongoDB storage.
```bash
@@ -562,6 +662,7 @@ curl -sS "$MONGO_BASE_URL/admin/api/v1/audit/log?limit=3" \
```
### S56 Guardrail chat override
+
Checks that a system-prompt guardrail overrides normal chat output.
```bash
@@ -572,6 +673,7 @@ curl -sS "$GR_BASE_URL/v1/chat/completions" \
```
### S57 Guardrail responses override
+
Checks the same guardrail path on `/v1/responses`.
```bash
@@ -582,6 +684,7 @@ curl -sS "$GR_BASE_URL/v1/responses" \
```
### S58 Guardrail audit and usage smoke
+
Reads admin evidence after the guardrail requests flush.
```bash
@@ -594,6 +697,7 @@ curl -sS "$GR_BASE_URL/admin/api/v1/usage/summary" | jq '.'
## 10. Alias cleanup
### S59 Delete OpenAI alias
+
Removes `qa-gpt-latest`.
```bash
@@ -601,6 +705,7 @@ curl -sS -X DELETE -i "$BASE_URL/admin/api/v1/aliases/qa-gpt-latest"
```
### S60 Delete Anthropic alias
+
Removes `qa-sonnet-thinking`.
```bash
@@ -610,6 +715,7 @@ curl -sS -X DELETE -i "$BASE_URL/admin/api/v1/aliases/qa-sonnet-thinking"
## 11. Audit failure coverage
### S61 Unsupported translated model is still written to audit log
+
Checks that a rejected translated request is still visible in audit logs with the requested model and error type.
```bash
@@ -624,6 +730,7 @@ curl -sS "$BASE_URL/admin/api/v1/audit/log?request_id=$REQUEST_ID&limit=5" \
```
### S62 Unsupported passthrough provider is still written to audit log
+
Checks that a rejected passthrough request is still visible in audit logs with the provider parsed from the path.
```bash
@@ -636,3 +743,229 @@ sleep 6
curl -sS "$BASE_URL/admin/api/v1/audit/log?request_id=$REQUEST_ID&limit=5" \
| jq '{total,entries:(.entries|map({request_id,path,model,provider,status_code,error_type}))}'
```
+
+## 12. Authenticated runtime features
+
+### S63 Auth-enabled dashboard runtime config
+
+Reads the allowlisted runtime flags for the live auth-enabled gateway.
+
+```bash
+curl -sS "$AUTH_BASE_URL/admin/api/v1/dashboard/config" \
+ -H "$ADMIN_AUTH_HEADER" \
+ | jq '.'
+```
+
+### S64 Create managed API key
+
+Creates one managed API key scoped to a release-specific user path and stores the one-time secret under `/tmp`.
+
+```bash
+AUTH_KEY_JSON=$(curl -sS -X POST "$AUTH_BASE_URL/admin/api/v1/auth-keys" \
+ -H "$ADMIN_AUTH_HEADER" \
+ -H 'Content-Type: application/json' \
+ -d "{\"name\":\"$QA_AUTH_KEY_NAME\",\"description\":\"Release e2e managed key\",\"user_path\":\"$QA_USER_PATH\"}")
+AUTH_KEY_VALUE=$(printf '%s\n' "$AUTH_KEY_JSON" \
+ | jq -er '.value | select(type == "string" and length > 0)') \
+ || {
+ echo "error: managed API key creation failed or did not return a usable one-time key value" >&2
+ printf '%s\n' "$AUTH_KEY_JSON" | jq '.' >&2 2>/dev/null || printf '%s\n' "$AUTH_KEY_JSON" >&2
+ exit 1
+ }
+(
+ umask 077
+ printf '%s\n' "$AUTH_KEY_JSON" > "$QA_AUTH_KEY_JSON"
+ printf '%s\n' "$AUTH_KEY_VALUE" > "$QA_AUTH_KEY_VALUE_FILE"
+)
+chmod 600 "$QA_AUTH_KEY_JSON" "$QA_AUTH_KEY_VALUE_FILE"
+printf '%s\n' "$AUTH_KEY_JSON" \
+ | jq '{id,name,user_path,active,redacted_value}'
+```
+
+### S65 Verify managed API key list
+
+Checks that the newly issued managed API key is visible and active.
+
+```bash
+curl -sS "$AUTH_BASE_URL/admin/api/v1/auth-keys" \
+ -H "$ADMIN_AUTH_HEADER" \
+ | jq ".[] | select(.name==\"$QA_AUTH_KEY_NAME\") | {id,name,user_path,active,expires_at,redacted_value}"
+```
+
+### S66 Create user-path-scoped workflow with cache disabled
+
+Creates a scoped workflow for `openai/gpt-4.1-nano` that disables cache for the managed-key user path.
+
+```bash
+WORKFLOW_JSON=$(curl -sS -X POST "$AUTH_BASE_URL/admin/api/v1/execution-plans" \
+ -H "$ADMIN_AUTH_HEADER" \
+ -H 'Content-Type: application/json' \
+ -d "{\"scope_provider\":\"openai\",\"scope_model\":\"gpt-4.1-nano\",\"scope_user_path\":\"$QA_USER_PATH\",\"name\":\"$QA_WORKFLOW_NAME\",\"description\":\"Disable cache for managed-key release e2e scope\",\"plan_payload\":{\"schema_version\":1,\"features\":{\"cache\":false,\"audit\":true,\"usage\":true,\"guardrails\":false,\"fallback\":false},\"guardrails\":[]}}")
+printf '%s\n' "$WORKFLOW_JSON" > "$QA_WORKFLOW_JSON"
+printf '%s\n' "$WORKFLOW_JSON" | jq -r '.id' > "$QA_WORKFLOW_ID_FILE"
+printf '%s\n' "$WORKFLOW_JSON" \
+ | jq '{id,name,scope,plan_payload}'
+```
+
+### S67 Verify scoped workflow detail
+
+Reads the created workflow back and confirms the normalized scope and effective feature projection.
+
+```bash
+WORKFLOW_ID=$(cat "$QA_WORKFLOW_ID_FILE")
+curl -sS "$AUTH_BASE_URL/admin/api/v1/execution-plans/$WORKFLOW_ID" \
+ -H "$ADMIN_AUTH_HEADER" \
+ | jq '{id,name,scope,plan_payload,effective_features}'
+```
+
+### S68 Managed-key request through scoped workflow
+
+Sends a request with the managed API key while also sending a conflicting `X-GoModel-User-Path` header.
+
+```bash
+API_KEY=$(cat "$QA_AUTH_KEY_VALUE_FILE")
+curl -sS -D - "$AUTH_BASE_URL/v1/chat/completions" \
+ -H "Authorization: Bearer $API_KEY" \
+ -H 'Content-Type: application/json' \
+ -H "X-Request-ID: $QA_AUTH_REQ1" \
+ -H 'X-GoModel-User-Path: /team/should-be-overridden' \
+ -d '{"model":"openai/gpt-4.1-nano","messages":[{"role":"user","content":"Reply with exactly QA_AUTH_CACHE_OFF_OK"}],"max_tokens":16}' \
+ | sed -n '1,20p'
+```
+
+### S69 Repeated managed-key request should still bypass cache
+
+Repeats the same request and expects another live provider response rather than `X-Cache: HIT`.
+
+```bash
+API_KEY=$(cat "$QA_AUTH_KEY_VALUE_FILE")
+curl -sS -D - "$AUTH_BASE_URL/v1/chat/completions" \
+ -H "Authorization: Bearer $API_KEY" \
+ -H 'Content-Type: application/json' \
+ -H "X-Request-ID: $QA_AUTH_REQ2" \
+ -H 'X-GoModel-User-Path: /team/should-be-overridden' \
+ -d '{"model":"openai/gpt-4.1-nano","messages":[{"role":"user","content":"Reply with exactly QA_AUTH_CACHE_OFF_OK"}],"max_tokens":16}' \
+ | sed -n '1,20p'
+```
+
+### S70 Audit evidence for managed-key scoped workflow
+
+Confirms that auth method, managed auth key ID, normalized user path, workflow ID, and no cache hit are all recorded together.
+
+```bash
+curl -sS "$AUTH_BASE_URL/admin/api/v1/audit/log?request_id=$QA_AUTH_REQ2&limit=5" \
+ -H "$ADMIN_AUTH_HEADER" \
+ | jq '{total,entries:(.entries|map({request_id,status_code,auth_method,auth_key_id,user_path,execution_plan_version_id,cache_type,answer:.data.response_body.choices[0].message.content}))}'
+```
+
+### S71 Global cache warm request with explicit user path
+
+Warms the global cache-enabled workflow using the master key and a cache-specific user path.
+
+```bash
+curl -sS -D - "$AUTH_BASE_URL/v1/chat/completions" \
+ -H "$ADMIN_AUTH_HEADER" \
+ -H 'Content-Type: application/json' \
+ -H "X-Request-ID: $QA_CACHE_REQ1" \
+ -H "X-GoModel-User-Path: $QA_CACHE_USER_PATH" \
+ -d "{\"model\":\"openai/gpt-4.1-nano\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly $QA_CACHE_REPLY\"}],\"max_tokens\":16}" \
+ | sed -n '1,20p'
+```
+
+### S72 Repeated global cache request should hit exact cache
+
+Repeats the same request and expects `X-Cache: HIT (exact)`.
+
+```bash
+curl -sS -D - "$AUTH_BASE_URL/v1/chat/completions" \
+ -H "$ADMIN_AUTH_HEADER" \
+ -H 'Content-Type: application/json' \
+ -H "X-Request-ID: $QA_CACHE_REQ2" \
+ -H "X-GoModel-User-Path: $QA_CACHE_USER_PATH" \
+ -d "{\"model\":\"openai/gpt-4.1-nano\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly $QA_CACHE_REPLY\"}],\"max_tokens\":16}" \
+ | sed -n '1,20p'
+```
+
+### S73 Cache overview filtered by user path
+
+Checks cache analytics after the exact-cache hit using the same tracked user path.
+
+```bash
+sleep 6
+curl -sS "$AUTH_BASE_URL/admin/api/v1/cache/overview?days=1&user_path=$QA_CACHE_USER_PATH" \
+ -H "$ADMIN_AUTH_HEADER" \
+ | jq '.'
+```
+
+### S74 Cached usage log filtered by user path
+
+Reads cached-only usage entries for the same exact-hit request path.
+
+```bash
+curl -sS "$AUTH_BASE_URL/admin/api/v1/usage/log?days=1&user_path=$QA_CACHE_USER_PATH&cache_mode=cached&limit=5" \
+ -H "$ADMIN_AUTH_HEADER" \
+ | jq '{total,entries:(.entries|map({request_id,cache_type,model,provider,endpoint,user_path,total_tokens}))}'
+```
+
+### S75 Invalid managed API key user path (negative)
+
+Verifies user-path validation for managed API key creation.
+
+```bash
+curl -sS -i -X POST "$AUTH_BASE_URL/admin/api/v1/auth-keys" \
+ -H "$ADMIN_AUTH_HEADER" \
+ -H 'Content-Type: application/json' \
+ -d '{"name":"qa-invalid-user-path","user_path":"/team/../alpha"}' \
+ | sed -n '1,20p'
+```
+
+### S76 Invalid workflow scope user path (negative)
+
+Verifies user-path validation for workflow creation.
+
+```bash
+curl -sS -i -X POST "$AUTH_BASE_URL/admin/api/v1/execution-plans" \
+ -H "$ADMIN_AUTH_HEADER" \
+ -H 'Content-Type: application/json' \
+ -d '{"scope_provider":"openai","scope_model":"gpt-4.1-nano","scope_user_path":"/team/../alpha","name":"qa-invalid-workflow-path","plan_payload":{"schema_version":1,"features":{"cache":true,"audit":true,"usage":true,"guardrails":false},"guardrails":[]}}' \
+ | sed -n '1,24p'
+```
+
+## 13. Authenticated cleanup
+
+### S77 Deactivate managed API key
+
+Deactivates the managed key created for the auth-enabled release run.
+
+```bash
+AUTH_KEY_ID=$(curl -sS "$AUTH_BASE_URL/admin/api/v1/auth-keys" \
+ -H "$ADMIN_AUTH_HEADER" \
+ | jq -r ".[] | select(.name==\"$QA_AUTH_KEY_NAME\") | .id")
+curl -sS -i -X POST "$AUTH_BASE_URL/admin/api/v1/auth-keys/$AUTH_KEY_ID/deactivate" \
+ -H "$ADMIN_AUTH_HEADER"
+```
+
+### S78 Deactivated managed API key is rejected
+
+Confirms that the same managed key can no longer authenticate requests.
+
+```bash
+API_KEY=$(cat "$QA_AUTH_KEY_VALUE_FILE")
+curl -sS -i "$AUTH_BASE_URL/v1/chat/completions" \
+ -H "Authorization: Bearer $API_KEY" \
+ -H 'Content-Type: application/json' \
+ -H "X-Request-ID: $QA_DEACTIVATED_REQ" \
+ -d '{"model":"openai/gpt-4.1-nano","messages":[{"role":"user","content":"Reply with exactly QA_AUTH_DEACTIVATED"}],"max_tokens":16}' \
+ | sed -n '1,20p'
+```
+
+### S79 Deactivate scoped workflow
+
+Deactivates the workflow created for the scoped managed-key release run.
+
+```bash
+WORKFLOW_ID=$(cat "$QA_WORKFLOW_ID_FILE")
+curl -sS -i -X POST "$AUTH_BASE_URL/admin/api/v1/execution-plans/$WORKFLOW_ID/deactivate" \
+ -H "$ADMIN_AUTH_HEADER"
+rm -f "$QA_AUTH_KEY_JSON" "$QA_AUTH_KEY_VALUE_FILE" "$QA_WORKFLOW_JSON" "$QA_WORKFLOW_ID_FILE"
+```