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
8 changes: 8 additions & 0 deletions open-sse/services/usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,14 @@ async function getKiroUsage(accessToken, providerSpecificData) {
};
}

// Social auth (Google/GitHub) - these use a different token format that may not work with AWS CodeWhisperer quota APIs
if (sawAuthError && (authMethod === "google" || authMethod === "github")) {
return {
message: "Kiro quota API authentication expired. Chat may still work.",
quotas: {},
};
}

if (sawAuthError) {
return {
message: "Kiro quota API rejected the current token. Chat may still work.",
Expand Down
154 changes: 103 additions & 51 deletions src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export default function DroidToolCard({
const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [modelList, setModelList] = useState([]);
const [modelInput, setModelInput] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
Expand All @@ -32,7 +33,8 @@ export default function DroidToolCard({

const getConfigStatus = () => {
if (!droidStatus?.installed) return null;
const currentConfig = droidStatus.settings?.customModels?.find(m => m.id === "custom:9Router-0");
// Check for any 9Router model entry (support multi-model: custom:9Router-0, custom:9Router-1, ...)
const currentConfig = droidStatus.settings?.customModels?.find(m => m.id?.startsWith("custom:9Router"));
if (!currentConfig) return "not_configured";
const localMatch = currentConfig.baseUrl?.includes("localhost") || currentConfig.baseUrl?.includes("127.0.0.1");
const cloudMatch = cloudEnabled && CLOUD_URL && currentConfig.baseUrl?.startsWith(CLOUD_URL);
Expand Down Expand Up @@ -71,18 +73,25 @@ export default function DroidToolCard({
}
};

// Pre-fill model list from existing config (supports multi-model)
useEffect(() => {
if (droidStatus?.installed && !hasInitializedModel.current) {
hasInitializedModel.current = true;
const customModel = droidStatus.settings?.customModels?.find(m => m.id === "custom:9Router-0");
if (customModel) {
if (customModel.model) setSelectedModel(customModel.model);
if (customModel.apiKey && apiKeys?.some(k => k.key === customModel.apiKey)) {
setSelectedApiKey(customModel.apiKey);
const existingModels = (droidStatus.settings?.customModels || [])
.filter(m => m.id?.startsWith("custom:9Router"))
.sort((a, b) => (a.index || 0) - (b.index || 0))
.map(m => m.model);
if (existingModels.length > 0) {
setModelList(existingModels);
} else {
// Legacy: single model stored as custom:9Router-0
const legacy = droidStatus.settings?.customModels?.find(m => m.id === "custom:9Router-0");
if (legacy?.model) {
setModelList([legacy.model]);
}
}
}
}, [droidStatus, apiKeys]);
}, [droidStatus]);

const checkDroidStatus = async () => {
setCheckingDroid(true);
Expand All @@ -107,21 +116,37 @@ export default function DroidToolCard({
return url.endsWith("/v1") ? url : `${url}/v1`;
};

const addModel = () => {
const val = modelInput.trim();
if (!val || modelList.includes(val)) return;
setModelList((prev) => [...prev, val]);
setModelInput("");
};

const removeModel = (id) => setModelList((prev) => prev.filter((m) => m !== id));

const handleModelSelect = (model) => {
if (!model.value || modelList.includes(model.value)) return;
setModelList((prev) => [...prev, model.value]);
setModalOpen(false);
};

const handleApplySettings = async () => {
setApplying(true);
setMessage(null);
try {
const keyToUse = selectedApiKey?.trim()
const keyToUse = selectedApiKey?.trim()
|| (apiKeys?.length > 0 ? apiKeys[0].key : null)
|| (!cloudEnabled ? "sk_9router" : null);

const res = await fetch("/api/cli-tools/droid-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(),
body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
model: selectedModel
models: modelList,
activeModel: modelList[0] || "",
}),
});
const data = await res.json();
Expand All @@ -146,8 +171,7 @@ export default function DroidToolCard({
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setSelectedModel("");
setSelectedApiKey("");
setModelList([]);
checkDroidStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
Expand All @@ -159,35 +183,28 @@ export default function DroidToolCard({
}
};

const handleModelSelect = (model) => {
setSelectedModel(model.value);
setModalOpen(false);
};

const getManualConfigs = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");

const settingsContent = {
customModels: [
{
model: selectedModel || "provider/model-id",
id: "custom:9Router-0",
index: 0,
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
displayName: selectedModel || "provider/model-id",
maxOutputTokens: 131072,
noImageSupport: false,
provider: "openai",
},
],
customModels: modelList.map((m, i) => ({
model: m,
id: `custom:9Router-${i}`,
index: i,
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
displayName: m,
maxOutputTokens: 131072,
noImageSupport: false,
provider: "openai",
})),
};

const platform = typeof navigator !== "undefined" && navigator.platform;
const isWindows = platform?.toLowerCase().includes("win");
const settingsPath = isWindows
const settingsPath = isWindows
? "%USERPROFILE%\\.factory\\settings.json"
: "~/.factory/settings.json";

Expand Down Expand Up @@ -242,12 +259,12 @@ export default function DroidToolCard({
<>
<div className="flex flex-col gap-2">
{/* Current Base URL */}
{droidStatus?.settings?.customModels?.find(m => m.id === "custom:9Router-0")?.baseUrl && (
{droidStatus?.settings?.customModels?.find(m => m.id?.startsWith("custom:9Router"))?.baseUrl && (
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Current</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
<span className="flex-1 px-2 py-1.5 text-xs text-text-muted truncate">
{droidStatus.settings.customModels.find(m => m.id === "custom:9Router-0").baseUrl}
{droidStatus.settings.customModels.find(m => m.id?.startsWith("custom:9Router")).baseUrl}
</span>
</div>
)}
Expand All @@ -256,12 +273,12 @@ export default function DroidToolCard({
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Base URL</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
<input
type="text"
value={getDisplayUrl()}
onChange={(e) => setCustomBaseUrl(e.target.value)}
placeholder="https://.../v1"
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
<input
type="text"
value={getDisplayUrl()}
onChange={(e) => setCustomBaseUrl(e.target.value)}
placeholder="https://.../v1"
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
{customBaseUrl && customBaseUrl !== baseUrl && (
<button onClick={() => setCustomBaseUrl("")} className="p-1 text-text-muted hover:text-primary rounded transition-colors" title="Reset to default">
Expand All @@ -285,13 +302,48 @@ export default function DroidToolCard({
)}
</div>

{/* Model */}
{/* Models */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Model</span>
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Models {modelList.length > 0 && <span className="text-primary">({modelList.length})</span>}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
<input type="text" value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} placeholder="provider/model-id" className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" />
<button onClick={() => setModalOpen(true)} disabled={!hasActiveProviders} className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
{selectedModel && <button onClick={() => setSelectedModel("")} className="p-1 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
<div className="flex-1 flex flex-col gap-1">
{/* Model list */}
{modelList.length > 0 && (
<div className="flex flex-col gap-0.5 mb-1">
{modelList.map((id) => (
<div key={id} className="flex items-center gap-1.5 px-2 py-1 bg-bg-secondary rounded border border-border">
<span className="flex-1 text-xs font-mono truncate">{id}</span>
<button onClick={() => removeModel(id)} className="text-text-muted hover:text-red-500 transition-colors shrink-0" title="Remove">
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</div>
))}
</div>
)}
{/* Model input row */}
<div className="flex items-center gap-1.5">
<input
type="text"
value={modelInput}
onChange={(e) => setModelInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addModel(); } }}
placeholder="provider/model-id"
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<button
onClick={() => setModalOpen(true)}
disabled={!hasActiveProviders}
className={`px-2 py-1.5 rounded border text-xs shrink-0 ${hasActiveProviders ? "bg-surface border-border hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select
</button>
<button onClick={addModel} disabled={!modelInput.trim()} className="px-2 py-1.5 rounded border bg-surface border-border hover:border-primary text-xs shrink-0 disabled:opacity-50" title="Add model">
<span className="material-symbols-outlined text-[14px]">add</span>
</button>
</div>
</div>
</div>
</div>

Expand All @@ -303,7 +355,7 @@ export default function DroidToolCard({
)}

<div className="flex items-center gap-2">
<Button variant="primary" size="sm" onClick={handleApplySettings} disabled={!selectedModel} loading={applying}>
<Button variant="primary" size="sm" onClick={handleApplySettings} disabled={modelList.length === 0} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!droidStatus?.has9Router} loading={restoring}>
Expand All @@ -322,7 +374,7 @@ export default function DroidToolCard({
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
selectedModel={null}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Factory Droid"
Expand All @@ -336,4 +388,4 @@ export default function DroidToolCard({
/>
</Card>
);
}
}
74 changes: 52 additions & 22 deletions src/app/api/cli-tools/droid-settings/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const readSettings = async () => {
// Check if settings has 9Router customModels
const has9RouterConfig = (settings) => {
if (!settings || !settings.customModels) return false;
return settings.customModels.some(m => m.id === "custom:9Router-0");
return settings.customModels.some(m => m.id?.startsWith("custom:9Router"));
};

// GET - Check droid CLI and read current settings
Expand Down Expand Up @@ -78,12 +78,17 @@ export async function GET() {
}

// POST - Update 9Router customModels (merge with existing settings)
// Accepts either `model` (string, legacy single-model) or `models` (array of strings, multi-model)
// Also accepts `activeModel` to set which model is active/primary
export async function POST(request) {
try {
const { baseUrl, apiKey, model } = await request.json();
const { baseUrl, apiKey, model, models, activeModel } = await request.json();

if (!baseUrl || !model) {
return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 });
// Accept either `models` (array) or `model` (string, legacy)
const modelsArray = Array.isArray(models) ? models.slice() : (typeof model === "string" ? [model] : []);

if (!baseUrl || modelsArray.length === 0) {
return NextResponse.json({ error: "baseUrl and at least one model are required" }, { status: 400 });
}

const droidDir = getDroidDir();
Expand All @@ -104,26 +109,51 @@ export async function POST(request) {
settings.customModels = [];
}

// Remove existing 9Router config if any
settings.customModels = settings.customModels.filter(m => m.id !== "custom:9Router-0");
// Remove all existing 9Router configs
settings.customModels = settings.customModels.filter(m => !m.id?.startsWith("custom:9Router"));

// Normalize baseUrl to ensure /v1 suffix
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
const keyToUse = apiKey || "your_api_key";

// Determine active model: prefer explicit activeModel, else first of modelsArray
// If activeModel is explicitly empty string, no model will be set as default
let defaultIndex = 0;
if (typeof activeModel === "string") {
if (activeModel === "") {
defaultIndex = -1; // signal: don't set a default
} else {
const idx = modelsArray.indexOf(activeModel);
defaultIndex = idx >= 0 ? idx : 0;
}
}

// Add entries for all requested models
// The first one (index 0) will be the default if defaultIndex >= 0
for (let i = 0; i < modelsArray.length; i++) {
const m = modelsArray[i];
if (!m || typeof m !== "string") continue;
settings.customModels.push({
model: m,
id: `custom:9Router-${i}`,
index: i,
baseUrl: normalizedBaseUrl,
apiKey: keyToUse,
displayName: m,
maxOutputTokens: 131072,
noImageSupport: false,
provider: "openai",
});
}

// Add new 9Router config
const customModel = {
model: model,
id: "custom:9Router-0",
index: 0,
baseUrl: normalizedBaseUrl,
apiKey: apiKey || "your_api_key",
displayName: model,
maxOutputTokens: 131072,
noImageSupport: false,
provider: "openai",
};

settings.customModels.unshift(customModel);
// Set default model if applicable
if (defaultIndex >= 0 && settings.customModels[defaultIndex]) {
// Reorder so the default comes first
const [defaultEntry] = settings.customModels.splice(defaultIndex, 1);
settings.customModels.unshift({ ...defaultEntry, index: 0 });
// Re-index the rest
settings.customModels.forEach((m, i) => { m.index = i; });
}

// Write settings
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
Expand Down Expand Up @@ -161,7 +191,7 @@ export async function DELETE() {

// Remove 9Router customModels
if (settings.customModels) {
settings.customModels = settings.customModels.filter(m => m.id !== "custom:9Router-0");
settings.customModels = settings.customModels.filter(m => !m.id?.startsWith("custom:9Router"));

// Remove customModels array if empty
if (settings.customModels.length === 0) {
Expand All @@ -180,4 +210,4 @@ export async function DELETE() {
console.log("Error resetting droid settings:", error);
return NextResponse.json({ error: "Failed to reset droid settings" }, { status: 500 });
}
}
}