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
3 changes: 3 additions & 0 deletions cmd/gomodel/docs/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions docs/features/virtual-models.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ Create and manage both in the admin dashboard at `Models -> New virtual model`.
Fill **Target model** to make a redirect; leave it empty to make an access
policy on the **Source** selector.

GoModel does not persist empty, ineffective access policies, whether saved from
the dashboard or through the admin API. If a saved policy has no target, user
paths, or description and its enabled state matches the inherited/default
access state, GoModel removes the stored row. It retains an otherwise empty
policy when that row still overrides a disabled default or an inherited
user-path restriction.

The rest of this page covers redirects.

## Use stable names
Expand Down
3 changes: 3 additions & 0 deletions docs/openapi.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 48 additions & 0 deletions internal/admin/dashboard/static/css/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -2260,6 +2260,26 @@ td.col-price {
color: var(--text-muted);
}

.model-redirect-remove-btn {
appearance: none;
margin-left: 4px;
padding: 0;
border: 0;
background: none;
color: var(--danger);
font-size: 11px;
cursor: pointer;
}

.model-redirect-remove-btn:hover:not(:disabled) {
text-decoration: underline;
}

.model-redirect-remove-btn:disabled {
opacity: 0.45;
cursor: default;
}

.provider-group-row td {
background: color-mix(in srgb, var(--accent) 6%, var(--bg));
padding-top: 12px;
Expand Down Expand Up @@ -2324,6 +2344,24 @@ td.col-price {
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
}

.model-kind-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
flex: 0 0 24px;
border: 1px solid color-mix(in srgb, var(--accent) 55%, var(--border));
border-radius: 999px;
background: var(--bg);
color: var(--accent);
}

.model-kind-icon-svg {
width: 14px;
height: 14px;
}

.inline-link {
padding: 0;
border: none;
Expand Down Expand Up @@ -6567,6 +6605,16 @@ body.conversation-drawer-open {
max-width: 220px;
}

.auth-key-actions-cell {
white-space: nowrap;
}

.auth-key-row-actions {
display: inline-flex;
align-items: center;
gap: 6px;
}

.auth-key-status-badge {
display: inline-block;
padding: 2px 10px;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,34 @@ test("auth key expirations render as a UTC date with the full UTC timestamp in t
assert.match(paginationBtnWithIconRule, /gap:\s*8px/);
});

test("auth key row actions stay inline and use titled icon buttons", () => {
const indexTemplate = readDashboardTemplateSource();
const css = readFixture("../../css/dashboard.css");
const powerIconTemplate = readFixture("../../../templates/power-icon.html");

assert.match(indexTemplate, /<th aria-label="Actions"><\/th>/);
assert.match(
indexTemplate,
/class="auth-key-actions-cell"[\s\S]*class="auth-key-row-actions"[\s\S]*class="table-action-btn table-icon-btn"[\s\S]*:aria-label="'Edit labels for API key ' \+ key\.name"[\s\S]*:title="'Edit labels for API key ' \+ key\.name"[\s\S]*@click="openAuthKeyLabelsEditor\(key\)"[\s\S]*{{template "edit-icon"}}/,
);
assert.match(
indexTemplate,
/class="table-action-btn table-action-btn-danger table-icon-btn"[\s\S]*:disabled="authKeyDeactivatingID === key\.id"[\s\S]*:aria-label="authKeyDeactivatingID === key\.id \? 'Deactivating API key ' \+ key\.name : 'Deactivate API key ' \+ key\.name"[\s\S]*:title="authKeyDeactivatingID === key\.id \? 'Deactivating API key ' \+ key\.name : 'Deactivate API key ' \+ key\.name"[\s\S]*@click="deactivateAuthKey\(key\)"[\s\S]*{{template "power-icon"}}/,
);
assert.match(
powerIconTemplate,
/{{define "power-icon"}}[\s\S]*class="table-icon-svg"[\s\S]*<path d="M12 2v10"><\/path>[\s\S]*{{end}}/,
);

const actionCellRule = readCSSRule(css, ".auth-key-actions-cell");
assert.match(actionCellRule, /white-space:\s*nowrap/);

const rowActionsRule = readCSSRule(css, ".auth-key-row-actions");
assert.match(rowActionsRule, /display:\s*inline-flex/);
assert.match(rowActionsRule, /align-items:\s*center/);
assert.match(rowActionsRule, /gap:\s*6px/);
});

test("workflow guardrail warning links directly to the top-level guardrails page", () => {
const indexTemplate = readDashboardTemplateSource();

Expand Down Expand Up @@ -1251,7 +1279,7 @@ test("alias rows use a shared icon-only edit action", () => {

assert.match(
modelTableTemplate,
/class="table-action-btn table-action-btn-danger table-icon-btn"[\s\S]*x-show="virtualModelsAvailable && aliasRowCanRemove\(row\)"[\s\S]*@click="removeAliasRow\(row\)"[\s\S]*{{template "trash-icon"}}[\s\S]*class="table-action-btn table-icon-btn"[\s\S]*:aria-label="'Edit alias ' \+ row\.alias\.name"[\s\S]*@click="openVirtualModelEditAlias\(row\.alias\)"[\s\S]*{{template "edit-icon"}}/,
/class="table-action-btn table-action-btn-danger table-icon-btn"[\s\S]*x-show="virtualModelsAvailable && aliasRowCanRemove\(row\)"[\s\S]*@click="removeAliasRow\(row\)"[\s\S]*{{template "trash-icon"}}[\s\S]*class="table-action-btn table-icon-btn table-action-btn-active"[\s\S]*:aria-label="'Edit alias ' \+ row\.alias\.name"[\s\S]*@click="openVirtualModelEditAlias\(row\.alias\)"[\s\S]*{{template "edit-icon"}}/,
);
assert.match(
modelTableTemplate,
Expand All @@ -1263,12 +1291,31 @@ test("alias rows use a shared icon-only edit action", () => {
);
assert.match(
modelTableTemplate,
/Redirects to <span class="mono font-size-md" x-text="aliasTargetLabel\(row\.masking_alias\)"><\/span>/,
/Redirects to <span class="mono font-size-md" x-text="aliasTargetLabel\(row\.masking_alias\)"><\/span>[\s\S]*class="model-redirect-remove-btn mono"[\s\S]*x-show="virtualModelsAvailable && rowRedirectCanRemove\(row\)"[\s\S]*@click="removeRedirectRow\(row\)">\[remove\]<\/button>/,
);
assert.match(
modelTableTemplate,
/class="model-kind-icon" x-show="row\.is_alias"[\s\S]*aria-label="Virtual model"[\s\S]*class="model-kind-icon-svg"/,
);
assert.match(
modelTableTemplate,
/class="model-kind-icon" x-show="!row\.is_alias && row\.masking_alias"[\s\S]*aria-label="Redirect"[\s\S]*class="model-kind-icon-svg"/,
);
assert.match(
modelTableTemplate,
/x-show="modelPricingOverridesAvailable"[\s\S]*@click="openModelPricingOverrideEdit\(row\)"[\s\S]*{{template "dollar-icon"}}[\s\S]*class="table-action-btn table-action-btn-danger table-icon-btn"[\s\S]*x-show="virtualModelsAvailable && rowRedirectCanRemove\(row\)"[\s\S]*@click="removeRedirectRow\(row\)"[\s\S]*{{template "trash-icon"}}/,
/x-show="modelPricingOverridesAvailable"[\s\S]*@click="openModelPricingOverrideEdit\(row\)"[\s\S]*{{template "dollar-icon"}}/,
);
assert.match(
modelTableTemplate,
/@click="openRateLimitInspectorForModel\(row\)"[\s\S]*{{template "gauge-icon"}}[\s\S]*class="table-action-btn table-icon-btn table-action-btn-active"[\s\S]*x-show="virtualModelsAvailable && row\.masking_alias && row\.masking_alias\.name"[\s\S]*@click="openVirtualModelEditAlias\(row\.masking_alias\)"[\s\S]*{{template "edit-icon"}}/,
);
assert.doesNotMatch(
modelTableTemplate,
/class="table-action-btn table-action-btn-danger table-icon-btn"[\s\S]*@click="removeRedirectRow\(row\)"/,
);
const redirectRemoveRule = readCSSRule(css, ".model-redirect-remove-btn");
assert.match(redirectRemoveRule, /font-size:\s*11px/);
assert.match(redirectRemoveRule, /color:\s*var\(--danger\)/);
assert.doesNotMatch(css, /\.data-table tr\.masked-model-row td/);
assert.match(indexTemplate, /{{template "model-table-body" \.}}/);
assert.match(
Expand Down
97 changes: 64 additions & 33 deletions internal/admin/dashboard/static/js/modules/virtual-models.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@
is_alias: false,
alias: null,
access,
kind_badge: '',
masking_alias: maskingAlias,
has_virtual_model: Boolean(maskingAlias || (access && access.override)),
alias_state_class: '',
Expand Down Expand Up @@ -117,7 +116,6 @@
is_alias: true,
alias,
access: null,
kind_badge: 'Virtual Model',
masking_alias: null,
source_model_exists: Boolean(sourceModel),
has_virtual_model: true,
Expand Down Expand Up @@ -488,14 +486,19 @@
}
}

const virtualRows = [];
const groups = new Map();
for (const row of rows) {
if (row && row.is_alias) {
virtualRows.push(row);
continue;
}
const providerName = String(row && row.provider_name || '').trim();
const providerType = String(row && row.provider_type || '').trim();
const key = providerName || providerType || 'unassigned';
const key = 'provider-group:' + (providerName || providerType || 'unassigned');
if (!groups.has(key)) {
groups.set(key, {
key: 'provider-group:' + key,
key,
provider_name: providerName,
provider_type: providerType,
display_name: this.providerGroupDisplayName(providerName, providerType),
Expand All @@ -516,7 +519,7 @@
group.rows.push(row);
}

return Array.from(groups.values())
const result = Array.from(groups.values())
.map((group) => {
const access = this.providerGroupAccess(group.provider_name, group.provider_type, overridesBySelector);
return {
Expand All @@ -527,6 +530,21 @@
};
})
.sort((a, b) => String(a.display_name || '').localeCompare(String(b.display_name || '')));
if (virtualRows.length > 0) {
result.unshift({
key: 'virtual-model-group',
is_virtual_models: true,
provider_name: '',
provider_type: '',
display_name: 'Virtual models',
type_label: '',
rows: virtualRows,
access: { selector: '' },
access_summary: '',
item_count_label: this.providerGroupItemCountLabel(virtualRows)
});
}
return result;
},

providerGroupDisplayName(providerName, providerType) {
Expand Down Expand Up @@ -735,19 +753,6 @@
return classes.join(' ');
},

// rowVirtualBadge returns the small badge label shown on a real model row
// that carries a virtual model (empty for plain rows and alias rows, which
// already show their own Virtual Model badge).
rowVirtualBadge(row) {
if (!row || row.is_alias || !row.has_virtual_model) {
return '';
}
if (row.masking_alias) {
return 'Redirect';
}
return 'Override';
},

aliasRowCanRemove(row) {
return Boolean(row && row.is_alias && row.alias && row.alias.name && !row.alias.managed);
},
Expand Down Expand Up @@ -1281,63 +1286,86 @@
if (!source) {
return;
}
await this.removeVirtualModelSource(source, row.key, 'Remove the virtual model alias "' + source + '"?');
await this.mutateVirtualModelRow({
rowKey: row.key,
confirmMessage: 'Remove the virtual model alias "' + source + '"?',
method: 'DELETE',
payload: { source },
operation: 'virtual model',
failureMessage: 'Failed to remove virtual model.',
notice: 'Virtual model removed.',
ignoreNotFound: true
});
},

async removeRedirectRow(row) {
if (!this.rowRedirectCanRemove(row) || this.rowDeletingKey) {
return;
}
const source = String(row.masking_alias.name || '').trim();
const alias = row.masking_alias;
const source = String(alias.name || '').trim();
if (!source) {
return;
}
await this.removeVirtualModelSource(source, row.key, 'Remove the redirect for "' + source + '"?');
await this.mutateVirtualModelRow({
rowKey: row.key,
confirmMessage: 'Remove the redirect for "' + source + '"? Other virtual model settings will be preserved.',
method: 'PUT',
payload: {
source,
user_paths: Array.isArray(alias.user_paths) ? alias.user_paths : [],
description: String(alias.description || '').trim(),
enabled: alias.enabled !== false
},
operation: 'virtual model redirect',
failureMessage: 'Failed to remove redirect.',
notice: 'Redirect removed. Other virtual model settings were preserved.'
});
},

async removeVirtualModelSource(source, rowKey, confirmMessage) {
async mutateVirtualModelRow(options) {
if (this.rowDeletingKey) {
return;
}
if (!this.confirmAction(confirmMessage)) {
if (!this.confirmAction(options.confirmMessage)) {
return;
}

this.rowDeletingKey = rowKey;
this.rowDeletingKey = options.rowKey;
this.aliasError = '';
this.aliasNotice = '';

try {
const request = this.adminRequestOptions({
method: 'DELETE',
body: JSON.stringify({ source })
method: options.method,
body: JSON.stringify(options.payload)
});
const res = await fetch('/admin/virtual-models', request);
if (res.status === 503) {
this.setVirtualModelsAvailable(false);
this.aliasError = 'Virtual models feature is unavailable.';
return;
}
if (res.status !== 404) {
const handled = this.handleFetchResponse(res, 'virtual model', request);
if (!(options.ignoreNotFound && res.status === 404)) {
const handled = this.handleFetchResponse(res, options.operation, request);
if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) {
return;
}
if (!handled) {
this.aliasError = res.status === 401
? 'Authentication required.'
: await this.aliasResponseMessage(res, 'Failed to remove virtual model.');
: await this.aliasResponseMessage(res, options.failureMessage);
return;
}
}
this.setVirtualModelsAvailable(true);

await Promise.all([this.fetchModels(), this.fetchVirtualModels()]);
this.syncDisplayModels();
this.aliasNotice = 'Virtual model removed.';
this.aliasNotice = options.notice;
} catch (e) {
console.error('Failed to delete virtual model:', e);
this.aliasError = 'Failed to remove virtual model.';
console.error(options.failureMessage, e);
this.aliasError = options.failureMessage;
} finally {
this.rowDeletingKey = '';
}
Expand Down Expand Up @@ -1593,12 +1621,15 @@
: await this.aliasResponseMessage(res, 'Failed to save virtual model.');
return;
}
const policyPruned = !isRedirect && res.status === 204;
this.setVirtualModelsAvailable(true);

await Promise.all([this.fetchModels(), this.fetchVirtualModels()]);
this.syncDisplayModels();
this.closeVirtualModelForm();
this.aliasNotice = isRedirect ? 'Alias saved.' : 'Model access saved.';
this.aliasNotice = isRedirect
? 'Alias saved.'
: (policyPruned ? 'Model access reset to inherited/default.' : 'Model access saved.');
} catch (e) {
console.error('Failed to save virtual model:', e);
this.vmFormError = 'Failed to save virtual model.';
Expand Down
Loading