Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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: 5 additions & 1 deletion app/(builder)/ycode/api/ai/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ import type { AgentContentBlock, AgentMessage } from '@/lib/agent/providers/type

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
// Vercel hard-kills the function at this limit without running catch/finally,
// so the stream just stops and the turn looks silently truncated. The agent
// loop stops itself earlier (MAX_RUN_MS in lib/agent/config.ts) to end runs
// gracefully — keep that budget below this value.
export const maxDuration = 800;

/**
* Lightweight in-process rate limiter with a per-tenant sliding window, bounding
Expand Down
4 changes: 3 additions & 1 deletion app/(builder)/ycode/api/settings/agent/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export async function GET() {
}
}

const providerIds = ['anthropic', 'openai', 'google'] as const;
const providerIds = ['anthropic', 'openai', 'google', 'xai'] as const;

const scopeSchema = z.enum(['all', 'personal']);

Expand All @@ -49,6 +49,7 @@ const putSchema = z.object({
anthropic: z.string().nullish(),
openai: z.string().nullish(),
google: z.string().nullish(),
xai: z.string().nullish(),
})
.partial()
.optional(),
Expand All @@ -59,6 +60,7 @@ const putSchema = z.object({
anthropic: scopeSchema.optional(),
openai: scopeSchema.optional(),
google: scopeSchema.optional(),
xai: scopeSchema.optional(),
})
.partial()
.optional(),
Expand Down
59 changes: 58 additions & 1 deletion app/(builder)/ycode/api/settings/agent/test/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { getAuthUser } from '@/lib/supabase-auth';
import type { AgentProviderId } from '@/lib/agent/models';

const bodySchema = z.object({
provider: z.enum(['anthropic', 'openai', 'google']).default('anthropic'),
provider: z.enum(['anthropic', 'openai', 'google', 'xai']).default('anthropic'),
// Key to test; falls back to the provider's currently configured key when
// omitted so the user can verify an already-saved configuration.
apiKey: z.string().optional(),
Expand Down Expand Up @@ -70,13 +70,70 @@ async function testKey(provider: AgentProviderId, apiKey: string): Promise<void>
await client.models.list();
return;
}
if (provider === 'xai') {
await testXaiKey(apiKey);
return;
}
const client = new GoogleGenAI({ apiKey });
await client.models.list({ config: { pageSize: 1 } });
}

/** A key problem xAI reported that should surface to the user as-is. */
class XaiKeyError extends Error {}

/**
* xAI API keys carry per-endpoint ACLs, so listing models (how the other
* providers are tested) 403s for keys that were never granted that endpoint —
* even when the key is fine for chat. GET /v1/api-key validates any key
* without requiring ACLs and reports its status and permissions.
*/
async function testXaiKey(apiKey: string): Promise<void> {
const response = await fetch('https://api.x.ai/v1/api-key', {
headers: { Authorization: `Bearer ${apiKey}` },
});

if (response.status === 401 || response.status === 403) {
throw new XaiKeyError('Invalid API key');
}
if (!response.ok) {
throw new XaiKeyError(`xAI API error: ${response.status} ${response.statusText}`);
}

const info = (await response.json()) as {
api_key_blocked?: boolean;
api_key_disabled?: boolean;
team_blocked?: boolean;
acls?: string[];
};

if (info.api_key_disabled || info.api_key_blocked) {
throw new XaiKeyError('API key is valid but disabled or blocked — re-enable it in the xAI Console');
}
// xAI blocks the whole team until it has credits, so this is usually a
// billing problem rather than a key problem.
if (info.team_blocked) {
throw new XaiKeyError('API key is valid but your xAI team is blocked — this usually means it has no credits yet. Add credits in the xAI Console.');
}

// The agent talks to the chat endpoint, so a key without that ACL will fail
// at build time even though it authenticates fine.
const acls = info.acls ?? [];
const hasChatAccess = acls.some(
(acl) => acl === 'api-key:endpoint:*' || acl === 'api-key:endpoint:chat',
);
if (!hasChatAccess) {
throw new XaiKeyError(
'API key is valid but has no chat endpoint permission — edit the key in the xAI Console and grant it the "chat" endpoint (or all endpoints)',
);
}
}

/** Map SDK auth/permission errors to a user-facing message, or null for
* unexpected failures (which surface as a 500). */
function toFriendlyError(provider: AgentProviderId, error: unknown): string | null {
if (error instanceof XaiKeyError) {
return error.message;
}
if (error instanceof Anthropic.AuthenticationError || error instanceof OpenAI.AuthenticationError) {
return 'Invalid API key';
}
Expand Down
32 changes: 19 additions & 13 deletions app/(builder)/ycode/components/ai/AgentKeyForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import { useState } from 'react';

import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field';
import { Icon } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { Spinner } from '@/components/ui/spinner';
import { agentSettingsApi } from '@/lib/api';
Expand Down Expand Up @@ -123,19 +125,23 @@ export default function AgentKeyForm({ provider, submitLabel, keyScope, onDone,
)}
</div>
{error && (
<div className="flex items-center gap-2">
<p className="text-xs text-destructive">{error}</p>
{allowUnverified && (
<button
type="button"
className="text-xs underline text-muted-foreground hover:text-foreground"
onClick={() => handleSubmit(true)}
disabled={isSubmitting}
>
Save anyway
</button>
)}
</div>
<Alert variant="destructive">
<Icon name="info" />
<AlertDescription>
<p>{error}</p>
{allowUnverified && (
<Button
variant="secondary"
size="xs"
className="mt-1"
onClick={() => handleSubmit(true)}
disabled={isSubmitting}
>
Save anyway
</Button>
)}
</AlertDescription>
</Alert>
)}
</Field>
);
Expand Down
59 changes: 55 additions & 4 deletions app/(builder)/ycode/components/ai/AiChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -337,10 +337,8 @@ export default function AiChatPanel({ embedded = false }: AiChatPanelProps) {
const messages = useAiChatStore((s) => s.messages);
const status = useAiChatStore((s) => s.status);
const error = useAiChatStore((s) => s.error);
const autoReview = useAiChatStore((s) => s.autoReview);
const model = useAiChatStore((s) => s.model);
const sendMessage = useAiChatStore((s) => s.sendMessage);
const setAutoReview = useAiChatStore((s) => s.setAutoReview);
const setModel = useAiChatStore((s) => s.setModel);
const revertTurn = useAiChatStore((s) => s.revertTurn);
const redoTurn = useAiChatStore((s) => s.redoTurn);
Expand Down Expand Up @@ -621,7 +619,12 @@ export default function AiChatPanel({ embedded = false }: AiChatPanelProps) {
/>
))}

{error && <ErrorNotice message={error} />}
{/* Turn failures render inside their own bubble (message.error),
so the trailing banner only shows errors that never attached
to a message (e.g. a failed chat load). */}
{error && messages[messages.length - 1]?.error !== error && (
<ErrorNotice message={error} />
)}
</div>

{showJumpToLatest && (
Expand Down Expand Up @@ -752,13 +755,15 @@ const PROVIDER_SHORT_LABELS: Record<AgentProviderId, string> = {
anthropic: 'Claude',
openai: 'OpenAI',
google: 'Google Gemini',
xai: 'Grok',
};

/** Brand icons keyed by provider (registered in the Icon component). */
const PROVIDER_ICONS: Record<AgentProviderId, IconProps['name']> = {
anthropic: 'claude',
openai: 'openai',
google: 'gemini',
xai: 'grok',
};

/** Shown when no AI provider is configured: offers a one-click setup dialog for
Expand Down Expand Up @@ -959,6 +964,8 @@ const MessageBubble = memo(function MessageBubble({
{!isActivelyStreaming && shortSummary && <MarkdownText text={shortSummary} />}

{!isActivelyStreaming && plainText && <MarkdownText text={plainText} />}

{!isActivelyStreaming && message.error && <ErrorNotice message={message.error} />}
</div>
);
});
Expand Down Expand Up @@ -1091,6 +1098,50 @@ function formatDuration(ms?: number): string {
return seconds ? `${minutes}m ${seconds}s` : `${minutes}m`;
}

/**
* Rotating header phrases for the stretches where the model is working but
* nothing visible streams (reasoning models can think silently for tens of
* seconds). Generic on purpose — real actions are narrated by the tool rows
* underneath; these just keep the header alive between them.
*/
const WORKING_PHRASES = [
'Working…',
'Thinking it through…',
'Planning the changes…',
'Working out the details…',
'Putting it together…',
'Still on it…',
];

/** Seconds each working phrase stays up before rotating to the next. */
const WORKING_PHRASE_SECONDS = 5;

/** Live turn header: rotates through working phrases and ticks the elapsed
* time every second, so the status visibly moves even while the model is
* silent. Mounted fresh for each streaming turn (unmounts when it ends). */
function LiveWorkingLabel() {
const [elapsed, setElapsed] = useState(0);

useEffect(() => {
const startedAt = Date.now();
const timer = setInterval(() => {
setElapsed(Math.round((Date.now() - startedAt) / 1000));
}, 1000);
return () => clearInterval(timer);
}, []);

const phrase = WORKING_PHRASES[
Math.floor(elapsed / WORKING_PHRASE_SECONDS) % WORKING_PHRASES.length
];

return (
<span>
{phrase}
{elapsed > 0 && <span className="tabular-nums text-muted-foreground/70"> {elapsed}s</span>}
</span>
);
}

/**
* Collapsible "Thought for Ns" header wrapping a turn's narration and tool
* steps. While streaming it stays expanded with a live spinner; once done it
Expand Down Expand Up @@ -1123,7 +1174,7 @@ function ThoughtDisclosure({
) : (
<Icon name="chevronRight" className={cn('size-3 transition-transform', open && 'rotate-90')} />
)}
<span>{streaming ? 'Working…' : `Thought for ${formatDuration(thinkingMs)}`}</span>
{streaming ? <LiveWorkingLabel /> : <span>{`Thought for ${formatDuration(thinkingMs)}`}</span>}
</button>
{expanded && (
<div className="ml-1 border-l border-border pl-2.5">
Expand Down
7 changes: 4 additions & 3 deletions app/(builder)/ycode/components/ai/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -524,16 +524,17 @@ function ModelPicker({
onChange: (model: string | null) => void;
}) {
// Models can be restricted in Settings → Agent, and a model is only usable
// when its provider has an API key; fall back to the full allowlist until
// the status has loaded.
// when its provider has an API key; fall back to the non-legacy allowlist
// until the status has loaded (legacy models need the stored allowlist to
// confirm the project still has them).
const agentStatus = useAgentSettingsStore((s) => s.status);
const options = agentStatus
? AGENT_MODELS.filter(
(option) =>
agentStatus.enabledModels.includes(option.id) &&
agentStatus.providers[option.provider]?.configured,
)
: AGENT_MODELS;
: AGENT_MODELS.filter((option) => !option.legacy);

const current = options.find((option) => option.id === model) ?? options[0];
return (
Expand Down
3 changes: 3 additions & 0 deletions app/(builder)/ycode/components/ai/ProviderLogo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const LOGOS: Record<AgentProviderId, { d: string; fillRule?: 'evenodd' }> = {
google: {
d: 'M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81',
},
xai: {
d: 'm19.25 5.08-9.52 9.67 6.64-4.96c.33-.24.79-.15.95.23.82 1.99.45 4.39-1.17 6.03-1.63 1.64-3.89 2.01-5.96 1.18l-2.26 1.06c3.24 2.24 7.18 1.69 9.64-.8 1.95-1.97 2.56-4.66 1.99-7.09-.82-3.56.2-4.98 2.29-7.89L22 2.3zm-9.53 9.67h.01zm-1.37 1.21c-2.33-2.25-1.92-5.72.06-7.73 1.47-1.48 3.87-2.09 5.97-1.2l2.25-1.05c-.41-.3-.93-.62-1.52-.84a7.45 7.45 0 0 0-8.13 1.65c-2.11 2.14-2.78 5.42-1.63 8.22.85 2.09-.54 3.57-1.95 5.07-.5.53-1 1.06-1.4 1.62z',
},
};

export default function ProviderLogo({ providerId, className }: ProviderLogoProps) {
Expand Down
18 changes: 15 additions & 3 deletions app/(builder)/ycode/settings/agent/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ interface KeyFeedback {
message: string;
}

/** A provider's picker models. Legacy models only show for projects that
* already have them enabled — new projects can't adopt a superseded model. */
function visibleProviderModels(providerId: AgentProviderId, enabledModels: string[]) {
return AGENT_MODELS.filter(
(option) =>
option.provider === providerId &&
(!option.legacy || enabledModels.includes(option.id)),
);
}

export default function AgentSettingsPage() {
const status = useAgentSettingsStore((s) => s.status);
const isLoading = useAgentSettingsStore((s) => s.isLoading);
Expand Down Expand Up @@ -201,6 +211,7 @@ export default function AgentSettingsPage() {
<ProviderCard
key={provider.id}
provider={provider}
enabledModels={enabledModels}
isConnected={status?.providers[provider.id]?.configured ?? false}
scope={status?.providers[provider.id]?.scope ?? null}
onOpenSettings={() => setSelectedProviderId(provider.id)}
Expand Down Expand Up @@ -340,13 +351,14 @@ function ProviderScopeBadge({ scope, className }: ProviderScopeBadgeProps) {

interface ProviderCardProps {
provider: AgentProviderOption;
enabledModels: string[];
isConnected: boolean;
scope: AgentKeyScope | null;
onOpenSettings: () => void;
}

function ProviderCard({ provider, isConnected, scope, onOpenSettings }: ProviderCardProps) {
const models = AGENT_MODELS.filter((option) => option.provider === provider.id);
function ProviderCard({ provider, enabledModels, isConnected, scope, onOpenSettings }: ProviderCardProps) {
const models = visibleProviderModels(provider.id, enabledModels);

return (
<button
Expand Down Expand Up @@ -405,7 +417,7 @@ function ProviderSheetContent({
const isConnected = keyStatus?.configured ?? false;
const usesEnvKey = keyStatus?.source === 'env';
const scope = keyStatus?.scope ?? null;
const models = AGENT_MODELS.filter((option) => option.provider === provider.id);
const models = visibleProviderModels(provider.id, enabledModels);

const handleScopeChange = async (forAllUsers: boolean) => {
try {
Expand Down
Loading