Skip to content
Open
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
40 changes: 40 additions & 0 deletions docs/frontend-ui-audit-2026-08-04/CodexSetup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Frontend UI Audit — CodexSetup

**File:** `src/scaffold/WizardSystem/variants/KeyVault/components/setup/CodexSetup.tsx` (269 LOC)
**Date:** 2026-08-04
**Auditor:** Codex session

## D1 — Raw HTML vs Design System

| Line | Element | Verdict | Reason | Suggested change |
| ---- | ----------- | ------- | ---------------------------------------------------------------------------------------------- | ---------------- |
| — | No findings | — | The changed interaction uses the existing `Button` and `InlineAlert` design-system components. | — |

## D2 — Arbitrary Tailwind Value vs Token

| Line | Value | Verdict | Reason | Suggested change |
| ---- | ----------- | ------- | -------------------------------------------------------------------------------------- | ---------------- |
| — | No findings | — | The feedback change introduces no arbitrary CSS variables, colors, or Tailwind values. | — |

## D3 — Hardcoded Sizes / Colors

| Line | Value | Verdict | Reason | Suggested change |
| ---- | ----------- | ------- | ------------------------------------------------------------------- | ---------------- |
| — | No findings | — | The feedback change introduces no hardcoded size or color literals. | — |

## D4 — Accessibility

| Line | Element | Verdict | Reason | Suggested change |
| ---- | ------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------- |
| 178 | Detect `Button` | keep with reason | The design-system button has a visible localized label, native keyboard semantics, and is disabled while detection is active. | — |
| 230 | Detection feedback region | keep with reason | Progress and success use a polite status region; failures use an assertive alert region and retain a dismiss control. | — |

## D5 — Visual Patterns Observed

- No new repeated visual pattern. The implementation reuses the existing inline-alert feedback pattern.

## Summary

- 0 fixes recommended
- 2 kept with documented reason
- 0 abstract candidates (>= 3 occurrences)
1 change: 1 addition & 0 deletions src-tauri/crates/key-vault/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ reqwest = { workspace = true }
regex = "1.9.5"
log = "0.4"
tracing = "0.1.37"
libc = "0.2"
uuid = { version = "1", features = ["v4"] }
dirs = "6.0"
sha2 = "0.10"
Expand Down
10 changes: 5 additions & 5 deletions src-tauri/crates/key-vault/src/key_store/service/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,7 @@ impl KeyService {

/// Save or update a key
pub fn save_key(&self, key: ModelKey) -> Result<ModelKey, String> {
self.update_store(|store| {
let entry = key.clone();
store.set(key);
entry
})
self.update_store(|store| store.set(key))
}

/// Record behaviorally-observed reasoning capability for `model` on key
Expand Down Expand Up @@ -236,6 +232,10 @@ impl KeyService {
if let Some(enabled) = enabled_models {
entry.enabled_models = enabled;
}
// A successful model refresh is authoritative. Removed models
// must not survive in enabled_models when callers omit that
// optional field, and caller-provided lists are normalized too.
entry.normalize_enabled_models();
if let Some(quota) = quota_info {
entry.quota_info = Some(quota);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,11 @@ fn deserialize_key_store(contents: &str) -> Result<LoadedKeyStore, serde_json::E
}

match serde_json::from_value::<ModelKey>(raw.clone()) {
Ok(key) => {
Ok(mut key) => {
// Older catalog refreshes could leave removed model ids in
// enabled_models. Normalize on hydration so every reader sees
// a consistent account even before the next persisted write.
key.normalize_enabled_models();
store.keys.insert(storage_id, key);
}
Err(error) => invalid_credentials.push(InvalidStoredCredential {
Expand Down
5 changes: 4 additions & 1 deletion src-tauri/crates/key-vault/src/key_store/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,13 @@ impl KeyStore {
}

/// Save or update a key
pub fn set(&mut self, mut key: ModelKey) {
pub fn set(&mut self, mut key: ModelKey) -> ModelKey {
key.normalize_enabled_models();
key.updated_at = Utc::now();
let saved = key.clone();
self.keys.insert(key.id.clone(), key);
self.updated_at = Utc::now();
saved
}

/// Delete key by agent type and optional ID
Expand Down
82 changes: 82 additions & 0 deletions src-tauri/crates/key-vault/src/key_store/tests/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,86 @@ fn test_key_crud() {
assert_eq!(empty.len(), 0);
}

#[test]
fn test_save_key_normalizes_enabled_models_to_available_catalog() {
let temp_dir = tempdir().unwrap();
let service = KeyService::new(Some(temp_dir.path().to_path_buf()));

let mut key = ModelKey::new(ModelType::Codex);
key.available_models = vec!["gpt-5.5".to_string(), "gpt-5.4".to_string()];
key.enabled_models = vec![
"gpt-5.6-sol".to_string(),
"gpt-5.5".to_string(),
"gpt-5.5".to_string(),
];

let saved = service.save_key(key).unwrap();

assert_eq!(saved.enabled_models, vec!["gpt-5.5".to_string()]);
assert_eq!(
service.get_key_by_id(&saved.id).unwrap().enabled_models,
vec!["gpt-5.5".to_string()]
);
let persisted: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(temp_dir.path().join("credentials.json")).unwrap(),
)
.unwrap();
assert_eq!(
persisted["credentials"][&saved.id]["enabled_models"],
serde_json::json!(["gpt-5.5"]),
"serialized credentials must not retain unavailable enabled models"
);
}

#[test]
fn test_load_normalizes_legacy_stale_enabled_models() {
let temp_dir = tempdir().unwrap();
let service = KeyService::new(Some(temp_dir.path().to_path_buf()));

let mut key = ModelKey::new(ModelType::Codex);
let key_id = key.id.clone();
key.available_models = vec!["gpt-5.5".to_string()];
key.enabled_models = vec!["gpt-5.6-sol".to_string(), "gpt-5.5".to_string()];

let mut raw_store = KeyStore::default();
raw_store.keys.insert(key_id.clone(), key);
std::fs::write(
temp_dir.path().join("credentials.json"),
serde_json::to_string_pretty(&raw_store).unwrap(),
)
.unwrap();

let loaded = service.get_key_by_id(&key_id).unwrap();
assert_eq!(loaded.enabled_models, vec!["gpt-5.5".to_string()]);
}

#[test]
fn test_model_refresh_removes_enabled_models_missing_from_new_catalog() {
let temp_dir = tempdir().unwrap();
let service = KeyService::new(Some(temp_dir.path().to_path_buf()));

let mut key = ModelKey::new(ModelType::Codex);
key.available_models = vec!["gpt-5.6-sol".to_string(), "gpt-5.5".to_string()];
key.enabled_models = key.available_models.clone();
let saved = service.save_key(key).unwrap();

service
.update_key_health(
&saved.id,
HealthStatus::Valid,
None,
Some(vec!["gpt-5.5".to_string()]),
None,
None,
None,
)
.unwrap();

let refreshed = service.get_key_by_id(&saved.id).unwrap();
assert_eq!(refreshed.available_models, vec!["gpt-5.5".to_string()]);
assert_eq!(refreshed.enabled_models, vec!["gpt-5.5".to_string()]);
}

#[test]
fn retired_gemini_cli_credentials_do_not_corrupt_the_vault() {
let temp_dir = tempdir().unwrap();
Expand Down Expand Up @@ -1296,6 +1376,7 @@ fn test_cross_type_env_zenmux_as_claude_code_uses_anthropic_endpoint() {

let mut zenmux_key = ModelKey::new(ModelType::ZenmuxApi);
zenmux_key.api_key = Some("sk-zenmux-test123".to_string());
zenmux_key.available_models = vec!["claude-sonnet-4-20250514".to_string()];
zenmux_key.enabled_models = vec!["claude-sonnet-4-20250514".to_string()];
let key_id = zenmux_key.id.clone();
service.save_key(zenmux_key).unwrap();
Expand Down Expand Up @@ -1350,6 +1431,7 @@ fn test_cross_type_env_atlascloud_as_claude_code_uses_anthropic_endpoint() {

let mut atlas_key = ModelKey::new(ModelType::AtlascloudApi);
atlas_key.api_key = Some("atlas-test-key".to_string());
atlas_key.available_models = vec!["zai-org/glm-5.1".to_string()];
atlas_key.enabled_models = vec!["zai-org/glm-5.1".to_string()];
// The stored /v1 URL is OpenAI-protocol; the Anthropic export must
// ignore it and use the bare host instead.
Expand Down
16 changes: 15 additions & 1 deletion src-tauri/crates/key-vault/src/key_store/types.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use chrono::{DateTime, NaiveDateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
// Deserializer is used by the flexible_datetime modules below
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use uuid::Uuid;

// Custom serde for flexible datetime parsing (naive timestamps without timezone)
Expand Down Expand Up @@ -560,6 +560,20 @@ impl ModelKey {
&& matches!(self.model_type, ModelType::Codex | ModelType::ClaudeCode)
}

/// Restore the persisted model-selection invariant after catalog changes:
/// every enabled model must still exist in the provider's available-model
/// catalog. Preserve user ordering while also removing duplicate rows.
///
/// This is deliberately owned by `ModelKey`, rather than individual UI or
/// runtime consumers, so old credentials and every write path converge on
/// the same representation.
pub fn normalize_enabled_models(&mut self) {
let available: HashSet<&str> = self.available_models.iter().map(String::as_str).collect();
let mut seen = HashSet::new();
self.enabled_models
.retain(|model| available.contains(model.as_str()) && seen.insert(model.clone()));
}

/// Mask sensitive data for display
pub fn mask_api_key(&self) -> Option<String> {
self.api_key.as_ref().map(|key| {
Expand Down
8 changes: 8 additions & 0 deletions src/features/TeamCollaboration/forkModelFallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ describe("isModelRunnableLocally", () => {
expect(isModelRunnableLocally("gpt-5.6-sol", [makeKey()])).toBe(false);
});

it("rejects a stale enabled model removed from the available catalog", () => {
expect(
isModelRunnableLocally("gpt-5.6-sol", [
makeKey({ enabled_models: ["gpt-5.6-sol"] }),
])
).toBe(false);
});

it("rejects when the only matching key is disabled", () => {
expect(
isModelRunnableLocally("deepseek-v4-pro", [makeKey({ enabled: false })])
Expand Down
16 changes: 12 additions & 4 deletions src/features/TeamCollaboration/forkModelFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,14 @@ export function isModelRunnableWithAccount(
): boolean {
const key = keys.find((candidate) => candidate.id === accountId);
if (!key || !keyIsUsable(key)) return false;
const available = new Set(key.available_models ?? []);
const enabled = new Set(key.enabled_models ?? []);
if (enabled.has(model)) return true;
if (available.has(model) && enabled.has(model)) return true;
return (key.model_variants ?? []).some(
(variant) => variant.model === model && enabled.has(variant.base_model)
(variant) =>
variant.model === model &&
available.has(variant.base_model) &&
enabled.has(variant.base_model)
);
}

Expand All @@ -36,10 +40,14 @@ export function isModelRunnableLocally(
if (isOrgiiTierModel(model)) return true;
return keys.some((key) => {
if (!keyIsUsable(key)) return false;
const available = new Set(key.available_models ?? []);
const enabled = new Set(key.enabled_models ?? []);
if (enabled.has(model)) return true;
if (available.has(model) && enabled.has(model)) return true;
return (key.model_variants ?? []).some(
(variant) => variant.model === model && enabled.has(variant.base_model)
(variant) =>
variant.model === model &&
available.has(variant.base_model) &&
enabled.has(variant.base_model)
);
});
}
Expand Down
6 changes: 4 additions & 2 deletions src/hooks/housekeeper/useHousekeeperConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ export function getHousekeeperModelCandidates(
): string[] {
if (!account) return [];
const candidates: string[] = [];
for (const model of account.enabledModels ?? [])
pushUnique(candidates, model);
const available = new Set(account.availableModels ?? []);
for (const model of account.enabledModels ?? []) {
if (available.has(model)) pushUnique(candidates, model);
}
for (const model of account.availableModels ?? [])
pushUnique(candidates, model);
for (const variant of account.modelVariants ?? []) {
Expand Down
21 changes: 21 additions & 0 deletions src/hooks/models/useModelAccountLookup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ describe("accountHasModel", () => {
expect(accountHasModel(account, "claude-opus-4-8-high")).toBe(false);
});

it("rejects enabled ids that are no longer in the available catalog", () => {
const account = claudeAccount({
availableModels: ["claude-opus-4-7"],
enabledModels: ["claude-opus-4-8"],
});

expect(accountHasModel(account, "claude-opus-4-8")).toBe(false);
expect(accountHasModel(account, "claude-opus-4-8-high")).toBe(false);
});

it("rejects unknown ids and disabled accounts", () => {
expect(accountHasModel(claudeAccount(), "claude-opus-4-8-xhigh")).toBe(
false
Expand All @@ -74,4 +84,15 @@ describe("buildAccountLookup", () => {
const lookup = buildAccountLookup([claudeAccount({ enabledModels: [] })]);
expect(lookup.has("claude-opus-4-8-high")).toBe(false);
});

it("does not count stale enabled ids from a different account catalog", () => {
const stale = claudeAccount({
id: "stale-key",
availableModels: ["claude-opus-4-7"],
enabledModels: ["claude-opus-4-8"],
});
const lookup = buildAccountLookup([claudeAccount(), stale]);

expect(lookup.get("claude-opus-4-8")?.totalKeys).toBe(1);
});
});
17 changes: 11 additions & 6 deletions src/hooks/models/useModelAccountLookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import { type KeyVaultAccount, useKeyVault } from "@src/hooks/keyVault";
import type { ModelAccountInfo } from "./types";

/**
* Returns true if `account` has `modelId` enabled.
* Returns true if `account` currently exposes and has enabled `modelId`.
*
* Two ways an id counts as enabled:
* - it is in `enabledModels` directly, or
* Two ways an id counts as selectable:
* - it is present in both `availableModels` and `enabledModels`, or
* - it is a variant row from `modelVariants` (e.g. a backend-synthesized
* effort rung like `claude-opus-4-8-high`) whose BASE model is enabled —
* effort rung like `claude-opus-4-8-high`) whose BASE model is available
* and enabled —
* variant ids never appear in enabledModels themselves, so gating on
* enabledModels alone hides every synthesized effort ladder from the
* picker's variant-edit affordance.
Expand All @@ -23,10 +24,14 @@ export function accountHasModel(
modelId: string
): boolean {
if (!account.enabled) return false;
const available = new Set(account.availableModels ?? []);
const enabled = new Set(account.enabledModels ?? []);
if (enabled.has(modelId)) return true;
if (available.has(modelId) && enabled.has(modelId)) return true;
return (account.modelVariants ?? []).some(
(variant) => variant.model === modelId && enabled.has(variant.base_model)
(variant) =>
variant.model === modelId &&
available.has(variant.base_model) &&
enabled.has(variant.base_model)
);
}

Expand Down
Loading
Loading