Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/advanced/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

<Tip>
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.
</Tip>

#### Storage

Storage is shared by audit logging, usage tracking, and future features like IAM.
Expand Down
4 changes: 4 additions & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
"group": "Getting Started",
"pages": ["getting-started/quickstart"]
},
{
"group": "Features",
"pages": ["features/cache"]
},
{
"group": "Guides",
"pages": [
Expand Down
96 changes: 96 additions & 0 deletions docs/features/cache.mdx
Original file line number Diff line number Diff line change
@@ -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.

<Note>
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.
</Note>

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.
1 change: 1 addition & 0 deletions docs/getting-started/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
4 changes: 2 additions & 2 deletions internal/admin/dashboard/static/css/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
46 changes: 27 additions & 19 deletions internal/admin/dashboard/static/js/modules/audit-list.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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);
}
}
};
}
};
}
Expand Down
78 changes: 78 additions & 0 deletions internal/admin/dashboard/static/js/modules/audit-list.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {})
Expand All @@ -15,6 +16,7 @@ function loadAuditListModuleFactory(overrides = {}) {
window
};
vm.createContext(context);
vm.runInContext(clipboardSource, context);
vm.runInContext(source, context);
return context.window.dashboardAuditListModule;
}
Expand Down Expand Up @@ -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);
});
Loading
Loading