Merge pull request #3641 from fosrl/dev

1.22.0
This commit is contained in:
Owen Schwartz
2026-08-25 17:19:46 -04:00
committed by GitHub
101 changed files with 2209 additions and 886 deletions
@@ -8,6 +8,8 @@ import ExitNodesTable, {
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
import { build } from "@server/build";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Remote Exit Nodes"
@@ -22,6 +24,10 @@ export const dynamic = "force-dynamic";
export default async function RemoteExitNodesPage(
props: RemoteExitNodesPageProps
) {
if (build != "saas") {
redirect("/");
}
const params = await props.params;
let remoteExitNodes: ListRemoteExitNodesResponse["remoteExitNodes"] = [];
try {
@@ -345,6 +345,7 @@ export default function AiProviderNetworkPage() {
ref={targetsFormRef}
orgId={orgId}
isHttp
isAiProvider
providerId={provider.providerId}
initialTargets={
isTargetModeSaved ? remoteTargets : []
@@ -682,6 +682,7 @@ export default function CreateAiProviderPage() {
<ProxyResourceTargetsForm
orgId={orgId}
isHttp
isAiProvider
onChange={(nextTargets) => {
targetsRef.current = nextTargets;
}}
@@ -298,101 +298,6 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
)}
/>
<FormField
control={form.control}
name="settingsLogRetentionDaysAISessions"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("logRetentionAISessionsLabel")}
</FormLabel>
<FormControl>
<Select
value={field.value.toString()}
onValueChange={(value) =>
field.onChange(
parseInt(value, 10)
)
}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectLogRetention"
)}
/>
</SelectTrigger>
<SelectContent>
{LOG_RETENTION_OPTIONS.filter(
(option) => {
if (
build != "saas"
) {
return true;
}
let maxDays: number;
if (
!subscriptionTier
) {
// No tier
maxDays = 3;
} else if (
subscriptionTier ==
"enterprise"
) {
// Enterprise - no limit
return true;
} else if (
subscriptionTier ==
"tier3"
) {
maxDays = 90;
} else if (
subscriptionTier ==
"tier2"
) {
maxDays = 30;
} else if (
subscriptionTier ==
"tier1"
) {
maxDays = 7;
} else {
// Default to most restrictive
maxDays = 3;
}
// Filter out options that exceed the max
// Special values: -1 (forever) and 9001 (end of year) should be filtered
if (
option.value <
0 ||
option.value >
maxDays
) {
return false;
}
return true;
}
).map((option) => (
<SelectItem
key={option.value}
value={option.value.toString()}
>
{t(option.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!env.flags.disableEnterpriseFeatures && (
<>
<PaidFeaturesAlert
@@ -774,6 +679,131 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
);
}}
/>
<FormField
control={form.control}
name="settingsLogRetentionDaysAISessions"
render={({ field }) => {
const isDisabled = !isPaidUser(
tierMatrix.aiSessionLogs
);
return (
<FormItem>
<FormLabel>
{t(
"logRetentionAISessionsLabel"
)}
</FormLabel>
<FormControl>
<Select
value={field.value.toString()}
onValueChange={(
value
) => {
if (
!isDisabled
) {
field.onChange(
parseInt(
value,
10
)
);
}
}}
disabled={
isDisabled
}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectLogRetention"
)}
/>
</SelectTrigger>
<SelectContent>
{LOG_RETENTION_OPTIONS.filter(
(
option
) => {
if (
build !=
"saas"
) {
return true;
}
let maxDays: number;
if (
!subscriptionTier
) {
// No tier
maxDays = 3;
} else if (
subscriptionTier ==
"enterprise"
) {
// Enterprise - no limit
return true;
} else if (
subscriptionTier ==
"tier3"
) {
maxDays = 90;
} else if (
subscriptionTier ==
"tier2"
) {
maxDays = 30;
} else if (
subscriptionTier ==
"tier1"
) {
maxDays = 7;
} else {
// Default to most restrictive
maxDays = 3;
}
// Filter out options that exceed the max
// Special values: -1 (forever) and 9001 (end of year) should be filtered
if (
option.value <
0 ||
option.value >
maxDays
) {
return false;
}
return true;
}
).map(
(
option
) => (
<SelectItem
key={
option.value
}
value={option.value.toString()}
>
{t(
option.label
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</>
)}
</form>
+13 -1
View File
@@ -3,11 +3,13 @@ import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { LogDataTable } from "@app/components/LogDataTable";
import { AiSessionChatView } from "@app/components/AiSessionChatView";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import LogRetentionWarning from "@app/components/LogRetentionWarning";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { useTranslations } from "next-intl";
@@ -15,6 +17,8 @@ import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
import { logQueries } from "@app/lib/queries";
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { ColumnDef } from "@tanstack/react-table";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
@@ -29,6 +33,7 @@ const capabilityLabels: Record<string, string> = {
openai_chat: "OpenAI Chat Completions",
openai_responses: "OpenAI Responses",
anthropic_messages: "Anthropic Messages",
v1_models: "Models List",
gemini_generate_content: "Gemini",
google_generate_content: "Vertex AI (Generate Content)",
google_raw_predict: "Vertex AI (Raw Predict)",
@@ -44,6 +49,7 @@ export default function AiSessionLogsPage() {
const searchParams = useSearchParams();
const { org } = useOrgContext();
const { isPaidUser } = usePaidStatus();
const [isExporting, startTransition] = useTransition();
@@ -133,7 +139,8 @@ export default function AiSessionLogsPage() {
...logQueries.aiSessions({
orgId: orgId as string,
filters: queryFilters
})
}),
enabled: isPaidUser(tierMatrix.aiSessionLogs) && build !== "oss"
});
const rows = isLoading ? generateSampleAiSessionLogs() : (data?.log ?? []);
@@ -645,6 +652,8 @@ export default function AiSessionLogsPage() {
description={t("aiSessionLogsDescription")}
/>
<PaidFeaturesAlert tiers={tierMatrix.aiSessionLogs} />
{org.org.settingsLogRetentionDaysAISessions === 0 && (
<LogRetentionWarning
orgId={orgId as string}
@@ -679,6 +688,9 @@ export default function AiSessionLogsPage() {
pageSize={pageSize}
expandable={true}
renderExpandedRow={renderExpandedRow}
disabled={
!isPaidUser(tierMatrix.aiSessionLogs) || build === "oss"
}
/>
</>
);
@@ -88,8 +88,8 @@ export default async function ClientResourcesPage(
siteNiceIds: siteResource.siteNiceIds,
niceId: siteResource.niceId,
enabled: siteResource.enabled,
tcpPortRangeString: siteResource.tcpPortRangeString || null,
udpPortRangeString: siteResource.udpPortRangeString || null,
tcpPortRangeString: siteResource.tcpPortRangeString ?? null,
udpPortRangeString: siteResource.udpPortRangeString ?? null,
disableIcmp: siteResource.disableIcmp || false,
authDaemonMode: siteResource.authDaemonMode ?? null,
authDaemonPort: siteResource.authDaemonPort ?? null,
@@ -113,6 +113,8 @@ type ProxyResourceTargetsFormProps = {
hideSaveButton?: boolean;
/** Hide the advanced mode toggle and always use non-advanced mode (e.g. AI providers) */
disableAdvancedMode?: boolean;
/** Targets picker is for an AI provider (changes which routing warnings are shown) */
isAiProvider?: boolean;
};
export const ProxyResourceTargetsForm = forwardRef<
@@ -131,7 +133,8 @@ export const ProxyResourceTargetsForm = forwardRef<
emptyMessage,
embedded = false,
hideSaveButton = false,
disableAdvancedMode = false
disableAdvancedMode = false,
isAiProvider = false
},
ref
) {
@@ -259,6 +262,14 @@ export const ProxyResourceTargetsForm = forwardRef<
})
);
const { data: remoteExitNodes = [] } = useQuery({
...orgQueries.remoteExitNodes({ orgId }),
enabled: build === "saas" && isAiProvider
});
const hasRemoteExitNodes = remoteExitNodes.some(
(node) => node.exitNodeId !== null
);
const updateTarget = useCallback(
(targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => {
@@ -972,6 +983,7 @@ export const ProxyResourceTargetsForm = forwardRef<
</div>
)}
{build === "saas" &&
!isAiProvider &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size > 1 && (
<p className="text-sm text-muted-foreground mt-3">
@@ -988,6 +1000,11 @@ export const ProxyResourceTargetsForm = forwardRef<
.
</p>
)}
{build === "saas" && isAiProvider && hasRemoteExitNodes && (
<p className="text-sm text-muted-foreground mt-3">
{t("aiProviderRemoteNodeTargetsWarning")}
</p>
)}
</>
);
+1 -1
View File
@@ -100,7 +100,7 @@ export default async function Page(props: {
loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name,
variant: idp.type
variant: idp.variant ?? idp.type
})) as LoginFormIDP[];
}
} else {
@@ -20,6 +20,7 @@ const CAPABILITY_LABEL_KEYS: Record<AiCapability, string> = {
openai_chat: "aiCapabilityOpenaiChat",
openai_responses: "aiCapabilityOpenaiResponses",
anthropic_messages: "aiCapabilityAnthropicMessages",
v1_models: "aiCapabilityV1Models",
gemini_generate_content: "aiCapabilityGeminiGenerateContent",
bedrock_model_invoke: "aiCapabilityBedrockModelInvoke",
google_generate_content: "aiCapabilityGoogleGenerateContent",
+5 -5
View File
@@ -79,7 +79,7 @@ function MessageBubble({ message }: { message: NormalizedAiMessage }) {
)}
</div>
<div
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap break-words ${
className={`min-w-0 max-w-[80%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap break-words ${
isUser
? "bg-primary text-primary-foreground"
: isTool
@@ -108,7 +108,7 @@ function RawFallbackBlock({
}) {
const pretty = prettyRaw(raw);
return (
<div className="rounded-md border bg-muted/30 p-3">
<div className="min-w-0 rounded-md border bg-muted/30 p-3">
<div className="mb-1 text-xs font-medium text-muted-foreground">
{label}
{pretty && unparsedLabel && (
@@ -117,7 +117,7 @@ function RawFallbackBlock({
</span>
)}
</div>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words text-xs text-muted-foreground">
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-all text-xs text-muted-foreground">
{pretty ?? noDataLabel}
</pre>
</div>
@@ -172,7 +172,7 @@ export function AiSessionChatView({
</Button>
</div>
{rawMode ? (
<div className="flex max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
<div className="flex min-w-0 max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
<RawFallbackBlock
label={t("aiSessionRequest")}
raw={normalizedRequest}
@@ -185,7 +185,7 @@ export function AiSessionChatView({
/>
</div>
) : (
<div className="flex max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
<div className="flex min-w-0 max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
{hasRequestMessages ? (
requestMessages!.map((message, i) => (
<MessageBubble key={`req-${i}`} message={message} />
+29 -1
View File
@@ -57,6 +57,7 @@ export interface Destination {
sendActionLogs: boolean;
sendConnectionLogs: boolean;
sendRequestLogs: boolean;
sendAISessionLogs: boolean;
lastError: string | null;
lastErrorAt: number | null;
createdAt: number;
@@ -180,6 +181,7 @@ export function HttpDestinationCredenza({
const [sendActionLogs, setSendActionLogs] = useState(false);
const [sendConnectionLogs, setSendConnectionLogs] = useState(false);
const [sendRequestLogs, setSendRequestLogs] = useState(false);
const [sendAISessionLogs, setSendAISessionLogs] = useState(false);
useEffect(() => {
if (open) {
@@ -190,6 +192,7 @@ export function HttpDestinationCredenza({
setSendActionLogs(editing?.sendActionLogs ?? false);
setSendConnectionLogs(editing?.sendConnectionLogs ?? false);
setSendRequestLogs(editing?.sendRequestLogs ?? false);
setSendAISessionLogs(editing?.sendAISessionLogs ?? false);
}
}, [open, editing]);
@@ -226,7 +229,8 @@ export function HttpDestinationCredenza({
sendAccessLogs,
sendActionLogs,
sendConnectionLogs,
sendRequestLogs
sendRequestLogs,
sendAISessionLogs
};
if (editing) {
await api.post(
@@ -778,6 +782,30 @@ export function HttpDestinationCredenza({
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="log-ai-session"
checked={sendAISessionLogs}
onCheckedChange={(v) =>
setSendAISessionLogs(v === true)
}
className="mt-0.5"
/>
<div>
<label
htmlFor="log-ai-session"
className="text-sm font-medium cursor-pointer"
>
{t("httpDestAISessionLogsTitle")}
</label>
<p className="text-xs text-muted-foreground mt-0.5">
{t(
"httpDestAISessionLogsDescription"
)}
</p>
</div>
</div>
</div>
</div>
</HorizontalTabs>
+16 -3
View File
@@ -313,6 +313,15 @@ export function LogDataTable<TData, TValue>({
}
}, [currentPage, table, isServerPagination]);
// Collapse any expanded rows whenever the page changes, since row ids
// are reused across pages and would otherwise show the wrong content
// in the same expanded position.
const pageIndex = table.getState().pagination.pageIndex;
useEffect(() => {
setExpandedRows(new Set());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageIndex]);
const handleTabChange = (value: string) => {
if (disabled) return;
@@ -515,9 +524,13 @@ export function LogDataTable<TData, TValue>({
}
className="p-4 bg-muted/50"
>
{renderExpandedRow(
row.original
)}
{/* w-0 min-w-full keeps this cell's content from */}
{/* blowing out the table's auto column widths */}
<div className="w-0 min-w-full">
{renderExpandedRow(
row.original
)}
</div>
</TableCell>
</TableRow>
)
+28 -1
View File
@@ -90,6 +90,7 @@ export function S3DestinationCredenza({
const [sendActionLogs, setSendActionLogs] = useState(false);
const [sendConnectionLogs, setSendConnectionLogs] = useState(false);
const [sendRequestLogs, setSendRequestLogs] = useState(false);
const [sendAISessionLogs, setSendAISessionLogs] = useState(false);
useEffect(() => {
if (open) {
@@ -98,6 +99,7 @@ export function S3DestinationCredenza({
setSendActionLogs(editing?.sendActionLogs ?? false);
setSendConnectionLogs(editing?.sendConnectionLogs ?? false);
setSendRequestLogs(editing?.sendRequestLogs ?? false);
setSendAISessionLogs(editing?.sendAISessionLogs ?? false);
}
}, [open, editing]);
@@ -121,7 +123,8 @@ export function S3DestinationCredenza({
sendAccessLogs,
sendActionLogs,
sendConnectionLogs,
sendRequestLogs
sendRequestLogs,
sendAISessionLogs
};
if (editing) {
await api.post(
@@ -510,6 +513,30 @@ export function S3DestinationCredenza({
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="s3-log-ai-session"
checked={sendAISessionLogs}
onCheckedChange={(v) =>
setSendAISessionLogs(v === true)
}
className="mt-0.5"
/>
<div>
<Label
htmlFor="s3-log-ai-session"
className="cursor-pointer font-medium"
>
{t("httpDestAISessionLogsTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t(
"httpDestAISessionLogsDescription"
)}
</p>
</div>
</div>
</div>
</div>
</HorizontalTabs>
@@ -49,6 +49,10 @@ const CLIENT_LOGOS = {
opencode: {
light: "/third-party/opencode-dark.svg",
dark: "/third-party/opencode-light.svg"
},
gemini: {
light: "/third-party/gemini-dark.svg",
dark: "/third-party/gemini-light.svg"
}
} as const;
@@ -65,7 +69,8 @@ export function AiClientConfigSection({
const descriptions: Record<string, string> = {
claude: t("aiClientConfigDescriptionClaude"),
codex: t("aiClientConfigDescriptionCodex"),
opencode: t("aiClientConfigDescriptionOpencode")
opencode: t("aiClientConfigDescriptionOpencode"),
gemini: t("aiClientConfigDescriptionGemini")
};
return (
@@ -129,6 +129,7 @@ export function ToggleableTrendChart(props: ToggleableTrendChartProps) {
payload?.[0]?.payload?.day
)
}
valueFormatter={valueFormatter}
/>
}
/>
@@ -172,6 +173,7 @@ export function ToggleableTrendChart(props: ToggleableTrendChartProps) {
payload?.[0]?.payload?.day
)
}
valueFormatter={valueFormatter}
/>
}
/>
+2 -1
View File
@@ -47,7 +47,8 @@ export function buildSeriesFromData(
export const currencyFormatter = new Intl.NumberFormat(undefined, {
style: "currency",
currency: "USD",
maximumFractionDigits: 2
minimumFractionDigits: 2,
maximumFractionDigits: 4
});
export const compactNumberFormatter = new Intl.NumberFormat(undefined, {
+16 -10
View File
@@ -135,6 +135,7 @@ type ChartTooltipContentProps = React.ComponentProps<"div"> & {
labelKey?: string;
color?: string;
labelClassName?: string;
valueFormatter?: (value: number) => string;
};
const ChartTooltipContent = React.forwardRef<
@@ -155,7 +156,8 @@ const ChartTooltipContent = React.forwardRef<
formatter,
color,
nameKey,
labelKey
labelKey,
valueFormatter
},
ref
) => {
@@ -302,19 +304,23 @@ const ChartTooltipContent = React.forwardRef<
item.name}
</span>
</div>
{item.value && (
{item.value !== undefined && (
<span className="font-mono font-medium tabular-nums text-foreground">
{!isNaN(
item.value as number
)
? new Intl.NumberFormat(
navigator.language,
{
maximumFractionDigits: 0
}
).format(
item.value as number
)
? valueFormatter
? valueFormatter(
item.value as number
)
: new Intl.NumberFormat(
navigator.language,
{
maximumFractionDigits: 0
}
).format(
item.value as number
)
: item.value.toLocaleString()}
</span>
)}
+1
View File
@@ -2,6 +2,7 @@ export const AI_CAPABILITIES = [
"openai_chat",
"openai_responses",
"anthropic_messages",
"v1_models",
"gemini_generate_content",
"bedrock_model_invoke",
"google_generate_content",
+68 -11
View File
@@ -1,10 +1,16 @@
export const AI_CLIENT_IDS = ["claude", "codex", "opencode"] as const;
export const AI_CLIENT_IDS = [
"claude",
"codex",
"opencode",
"gemini"
] as const;
export type AiClientId = (typeof AI_CLIENT_IDS)[number];
export const AI_CLIENT_NAMES: Record<AiClientId, string> = {
claude: "Claude Code",
codex: "Codex",
opencode: "OpenCode"
opencode: "OpenCode",
gemini: "Gemini CLI"
};
/** Auth as supplied by callers: the real key isn't fetched yet. */
@@ -43,8 +49,15 @@ export type AiClientGuide = {
presets: AiConfigPreset[];
};
/**
* Placeholder key for keyless (private/site) resources. Those resources need
* no credential, but most clients refuse to start without *some* key set, so
* they get an obviously-inert one rather than an omitted field.
*/
const KEYLESS_PLACEHOLDER_KEY = "none";
function keyValue(auth: AiClientAuth): string {
return auth.mode === "keyed" ? auth.key : "-";
return auth.mode === "keyed" ? auth.key : KEYLESS_PLACEHOLDER_KEY;
}
function block(
@@ -74,7 +87,7 @@ export function aiConfigBlockHasPlaceholders(block: AiConfigBlock): boolean {
}
function buildCli(
clientArg: "claude" | "codex" | "opencode",
clientArg: "claude" | "codex" | "opencode" | "gemini",
auth: AiClientAuth,
resourceNiceId?: string
): AiConfigBlock[] {
@@ -126,7 +139,7 @@ function buildClaudeGuide(
(key) =>
[
`export ANTHROPIC_BASE_URL=${endpoint}`,
`export ANTHROPIC_API_KEY=${auth.mode === "keyed" ? key : "none"}`,
`export ANTHROPIC_API_KEY=${key}`,
"claude"
].join("\n"),
auth
@@ -328,7 +341,7 @@ function buildOpencodeGuide(
"More providers",
() =>
"OpenCode configures providers individually, so Anthropic and OpenAI are just the ones set up above. " +
'You can point any other OpenCode-supported provider (e.g. "openrouter", "google", "groq") at this gateway the same way: add a matching entry under "provider" in opencode.json, and under auth.json if it needs an API key.',
'You can point any other OpenCode-supported provider (e.g. "openrouter", "google", "groq") at this gateway the same way: add a matching entry under "provider" in opencode.json, and a matching key in auth.json.',
auth,
"steps"
);
@@ -342,10 +355,53 @@ function buildOpencodeGuide(
id: "default",
label: "Default",
relation: "steps",
blocks:
auth.mode === "keyed"
? [config, authFile, moreProviders]
: [config, moreProviders]
// auth.json is written even for keyless resources: OpenCode
// refuses to start a provider with no key at all ("OpenAI API
// key is missing"), so it gets the inert placeholder instead.
blocks: [config, authFile, moreProviders]
}
]
};
}
function buildGeminiGuide(
endpoint: string,
auth: AiClientAuth,
resourceNiceId?: string
): AiClientGuide {
const defaultEnv = block(
"gemini-default-env",
"~/.gemini/.env",
(key) =>
[
`GOOGLE_GEMINI_BASE_URL=${endpoint}`,
`GEMINI_API_KEY=${key}`
].join("\n"),
auth
);
const defaultShell = block(
"gemini-default-shell",
"Shell",
(key) =>
[
`export GOOGLE_GEMINI_BASE_URL=${endpoint}`,
`export GEMINI_API_KEY=${key}`,
"gemini"
].join("\n"),
auth
);
return {
id: "gemini",
name: AI_CLIENT_NAMES.gemini,
cli: buildCli("gemini", auth, resourceNiceId),
presets: [
{
id: "default",
label: "Default",
relation: "options",
blocks: [defaultEnv, defaultShell]
}
]
};
@@ -361,7 +417,8 @@ const GUIDE_BUILDERS: Record<
> = {
claude: buildClaudeGuide,
codex: buildCodexGuide,
opencode: buildOpencodeGuide
opencode: buildOpencodeGuide,
gemini: buildGeminiGuide
};
export function buildAiClientGuide(
+8 -3
View File
@@ -38,12 +38,12 @@ export const AI_PROVIDER_DEFAULTS: Record<
openai: {
upstreamUrl: "https://api.openai.com/v1",
authType: "bearer",
capabilities: ["openai_chat", "openai_responses"]
capabilities: ["openai_chat", "openai_responses", "v1_models"]
},
anthropic: {
upstreamUrl: "https://api.anthropic.com",
authType: "x-api-key",
capabilities: ["anthropic_messages"]
capabilities: ["anthropic_messages", "v1_models"]
},
googleGemini: {
upstreamUrl: "https://generativelanguage.googleapis.com",
@@ -63,7 +63,12 @@ export const AI_PROVIDER_DEFAULTS: Record<
microsoftFoundry: {
upstreamUrl: null,
authType: "bearer",
capabilities: ["openai_chat", "openai_responses", "anthropic_messages"]
capabilities: [
"openai_chat",
"openai_responses",
"anthropic_messages",
"v1_models"
]
},
openRouter: {
upstreamUrl: "https://openrouter.ai/api/v1",
+2 -2
View File
@@ -43,8 +43,8 @@ export async function fetchSiteResourceByNiceId(
aliasAddress: match.aliasAddress || null,
siteNiceIds: match.siteNiceIds,
niceId: match.niceId,
tcpPortRangeString: match.tcpPortRangeString || null,
udpPortRangeString: match.udpPortRangeString || null,
tcpPortRangeString: match.tcpPortRangeString ?? null,
udpPortRangeString: match.udpPortRangeString ?? null,
disableIcmp: match.disableIcmp || false,
authDaemonMode: match.authDaemonMode ?? null,
authDaemonPort: match.authDaemonPort ?? null,
+12
View File
@@ -59,6 +59,7 @@ import type {
import type { GetResourceResponse } from "@server/routers/resource/getResource";
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
import type { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
import type { ListRolesResponse } from "@server/routers/role";
import type { ListSitesResponse } from "@server/routers/site";
import type {
@@ -330,6 +331,17 @@ export const orgQueries = {
}
}),
remoteExitNodes: ({ orgId }: { orgId: string }) =>
queryOptions({
queryKey: ["ORG", orgId, "REMOTE_EXIT_NODES"] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListRemoteExitNodesResponse>
>(`/org/${orgId}/remote-exit-nodes`, { signal });
return res.data.data.remoteExitNodes;
}
}),
labels: ({
orgId,
query,