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
10 changes: 8 additions & 2 deletions apps/web/src/components/upstream-edit/CodexConfigPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import type { CodexImportTab, CodexPkceStartResult } from './codex-import-types.
import CodexAccountCard from './CodexAccountCard.vue';
import CodexImportTabs from './CodexImportTabs.vue';
import { callApi, useApi } from '../../api/client.ts';
import type { UpstreamRecord } from '../../api/types.ts';
import type { ProxyFallbackEntry, UpstreamRecord } from '../../api/types.ts';
import { Button, Spinner } from '@floway-dev/ui';

const props = defineProps<{
mode: 'create' | 'edit';
record: UpstreamRecord | null;
// Current edit-form chain; forwarded into refresh-now so a refresh fired
// before saving uses the in-progress chain.
proxyFallbackList: ProxyFallbackEntry[];
}>();

const emit = defineEmits<{
Expand Down Expand Up @@ -85,7 +88,10 @@ const submit = async () => {
const refreshTokenNow = async () => {
refreshing.value = true;
const { data, error } = await callApi<UpstreamRecord>(
() => api.api.upstreams[':id']['codex-refresh-now'].$post({ param: { id: props.record!.id }, json: {} }),
() => api.api.upstreams[':id']['codex-refresh-now'].$post({
param: { id: props.record!.id },
json: { proxy_fallback_list: props.proxyFallbackList },
}),
);
refreshing.value = false;
if (error) { emit('error', error.message); return; }
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/upstream-edit/CopilotConfigPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

import CopilotDeviceFlow from './CopilotDeviceFlow.vue';
import CopilotInfo from './CopilotInfo.vue';
import type { CopilotQuotaSnapshot, CopilotUpstreamConfig, UpstreamRecord } from '../../api/types.ts';
import type { CopilotQuotaSnapshot, CopilotUpstreamConfig, ProxyFallbackEntry, UpstreamRecord } from '../../api/types.ts';

defineProps<{
record: UpstreamRecord | null;
initialQuota?: CopilotQuotaSnapshot | null;
initialQuotaError?: string | null;
proxyFallbackList: ProxyFallbackEntry[];
}>();

defineEmits<{ completed: [upstream: UpstreamRecord | undefined] }>();
Expand All @@ -21,5 +22,5 @@ defineEmits<{ completed: [upstream: UpstreamRecord | undefined] }>();
:initial-quota="initialQuota"
:initial-quota-error="initialQuotaError"
/>
<CopilotDeviceFlow v-else @completed="u => $emit('completed', u)" />
<CopilotDeviceFlow v-else :proxy-fallback-list="proxyFallbackList" @completed="u => $emit('completed', u)" />
</template>
12 changes: 10 additions & 2 deletions apps/web/src/components/upstream-edit/CopilotDeviceFlow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@
import { onUnmounted, ref } from 'vue';

import { callApi, useApi } from '../../api/client.ts';
import type { DeviceFlowPoll, DeviceFlowStart, UpstreamRecord } from '../../api/types.ts';
import type { DeviceFlowPoll, DeviceFlowStart, ProxyFallbackEntry, UpstreamRecord } from '../../api/types.ts';
import { Button, Code, Spinner } from '@floway-dev/ui';

const props = defineProps<{
// Current edit-form chain; forwarded into the poll body so the
// GitHub-side egress honors the in-progress chain.
proxyFallbackList: ProxyFallbackEntry[];
}>();

const emit = defineEmits<{ completed: [upstream: UpstreamRecord | undefined] }>();

const api = useApi();
Expand All @@ -26,7 +32,9 @@ const stopPolling = () => {
const pollOnce = async (currentInterval: number) => {
if (!flow.value) return;
const { data, error: err } = await callApi<DeviceFlowPoll>(
() => api.api.upstreams.copilot.auth.poll.$post({ json: { device_code: flow.value!.device_code } }),
() => api.api.upstreams.copilot.auth.poll.$post({
json: { device_code: flow.value!.device_code, proxy_fallback_list: props.proxyFallbackList },
}),
);
if (err) return; // Transient — keep polling.
if (!data) return;
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/upstream-edit/UpstreamConfigPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ onBeforeUnmount(() => floorObserver?.disconnect());
:record="record"
:initial-quota="initialCopilotQuota"
:initial-quota-error="initialCopilotQuotaError"
:proxy-fallback-list="proxyFallbackList"
@completed="u => $emit('copilot-completed', u)"
/>
</section>
Expand All @@ -165,6 +166,7 @@ onBeforeUnmount(() => floorObserver?.disconnect());
<CodexConfigPanel
:mode="mode"
:record="record"
:proxy-fallback-list="proxyFallbackList"
@imported="u => $emit('codex-imported', u)"
@error="m => $emit('codex-error', m)"
/>
Expand Down
19 changes: 11 additions & 8 deletions packages/gateway/src/control-plane/auth/github-device-flow.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { directFetcher, type Fetcher } from '@floway-dev/provider';
import { githubHeaders, isCopilotAccountType, type CopilotAccountType } from '@floway-dev/provider-copilot';

export interface GitHubUser {
Expand All @@ -18,8 +19,10 @@ interface GitHubDeviceFlowStart {
interval: number;
}

export const startGitHubDeviceFlow = async () => {
const resp = await fetch('https://github.com/login/device/code', {
// All GitHub egress accepts a Fetcher so the copilot auth poll can forward
// the operator's edit-form proxy override; absent that, direct egress.
export const startGitHubDeviceFlow = async (fetcher: Fetcher = directFetcher) => {
const resp = await fetcher('https://github.com/login/device/code', {
method: 'POST',
headers: {
'content-type': 'application/json',
Expand All @@ -40,8 +43,8 @@ export const startGitHubDeviceFlow = async () => {
return { ok: true as const, data };
};

export const pollGitHubDeviceFlow = async (deviceCode: string) => {
const resp = await fetch('https://github.com/login/oauth/access_token', {
export const pollGitHubDeviceFlow = async (deviceCode: string, fetcher: Fetcher = directFetcher) => {
const resp = await fetcher('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'content-type': 'application/json',
Expand All @@ -64,8 +67,8 @@ export const pollGitHubDeviceFlow = async (deviceCode: string) => {
};
};

export const fetchGitHubUser = async (githubToken: string) => {
const userResp = await fetch('https://api.github.com/user', {
export const fetchGitHubUser = async (githubToken: string, fetcher: Fetcher = directFetcher) => {
const userResp = await fetcher('https://api.github.com/user', {
headers: {
authorization: `token ${githubToken}`,
accept: 'application/json',
Expand All @@ -77,8 +80,8 @@ export const fetchGitHubUser = async (githubToken: string) => {
return (await userResp.json()) as GitHubUser;
};

export const detectAccountType = async (githubToken: string): Promise<CopilotAccountType> => {
const resp = await fetch('https://api.github.com/copilot_internal/user', {
export const detectAccountType = async (githubToken: string, fetcher: Fetcher = directFetcher): Promise<CopilotAccountType> => {
const resp = await fetcher('https://api.github.com/copilot_internal/user', {
headers: githubHeaders(githubToken),
});
if (!resp.ok) throw new Error(`GitHub Copilot account type detection failed: ${resp.status} ${await resp.text()}`);
Expand Down
9 changes: 8 additions & 1 deletion packages/gateway/src/control-plane/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,9 @@ export const fetchModelsBody = z.object({

export const copilotAuthPollBody = z.object({
device_code: z.string().min(1),
// Edit-form override routing every GitHub-side call through the
// operator's in-progress chain. See proxy-resolution.ts.
proxy_fallback_list: proxyFallbackListSchema.optional(),
});

// --- codex import / PKCE / refresh ---
Expand Down Expand Up @@ -307,7 +310,11 @@ export const codexReimportBody = z.object({
...codexCredentialFields,
}).refine(requireExactlyOneCredential, codexCredentialRefineMessage);

export const codexRefreshNowBody = z.object({});
export const codexRefreshNowBody = z.object({
// Edit-form override; absent falls back to the persisted row's list. See
// proxy-resolution.ts.
proxy_fallback_list: proxyFallbackListSchema.optional(),
});

// --- proxies ---
//
Expand Down
70 changes: 70 additions & 0 deletions packages/gateway/src/control-plane/upstreams/proxy-resolution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { createFetcher, type ProxyEntry } from '../../dial/fetcher.ts';
import { createPerRequestFetcherForAdmin } from '../../dial/per-request.ts';
import { getRepo } from '../../repo/index.ts';
import { DIRECT_PROXY_ID, normalizeProxyFallbackList } from '../../repo/proxy-fallback-list.ts';
import { getSocketDial } from '@floway-dev/platform';
import { directFetcher, type Fetcher, type ProxyFallbackEntry } from '@floway-dev/provider';
import { parseProxyUri, type ProxyUriError, runProxiedRequest } from '@floway-dev/proxy';

// Fetcher resolution for control-plane operations that fire from the
// dashboard edit form, where the in-progress proxy_fallback_list must take
// precedence over whatever is persisted. The override path validates proxy
// ids against the catalog and throws on unknown / malformed entries; the
// persisted path reuses the per-request fetcher bound to the saved row.
export const resolveControlPlaneFetcher = async (opts: {
override?: readonly ProxyFallbackEntry[];
upstreamId?: string;
}): Promise<Fetcher> => {
if (opts.override !== undefined) {
return await buildOverrideFetcher(opts.override, opts.upstreamId ?? 'draft');
}
if (opts.upstreamId !== undefined) {
return (await createPerRequestFetcherForAdmin())(opts.upstreamId);
}
return directFetcher;
};

const buildOverrideFetcher = async (rawList: readonly ProxyFallbackEntry[], upstreamId: string): Promise<Fetcher> => {
const list = normalizeProxyFallbackList(rawList);
const referenced = new Set(list.filter(entry => entry.id !== DIRECT_PROXY_ID).map(entry => entry.id));
if (referenced.size === 0) {
return directFetcher;
}

const repo = getRepo();
const proxies = await repo.proxies.list();
const proxyById = new Map<string, ProxyEntry>();
const parseErrors = new Map<string, ProxyUriError>();
for (const p of proxies) {
if (!referenced.has(p.id)) continue;
try {
proxyById.set(p.id, {
config: parseProxyUri(p.url),
dialTimeoutMs: p.dialTimeoutSeconds === null ? null : p.dialTimeoutSeconds * 1000,
});
} catch (err) {
parseErrors.set(p.id, err as ProxyUriError);
}
}

const unknown = list.find(entry => entry.id !== DIRECT_PROXY_ID && !proxyById.has(entry.id) && !parseErrors.has(entry.id));
if (unknown !== undefined) {
throw new Error(`unknown proxy id in fallback list: ${unknown.id}`);
}
const bad = list.find(entry => parseErrors.has(entry.id));
if (bad !== undefined) {
const err = parseErrors.get(bad.id)!;
throw new Error(`malformed proxy ${bad.id}: ${err.message}`);
}

return createFetcher({
repo,
upstreamId,
fallbackList: list,
currentColo: null,
proxyById,
runProxied: runProxiedRequest,
runDirect: directFetcher,
socketDial: getSocketDial,
});
};
35 changes: 25 additions & 10 deletions packages/gateway/src/control-plane/upstreams/routes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Context } from 'hono';
import type { z } from 'zod';

import { resolveControlPlaneFetcher } from './proxy-resolution.ts';
import { upstreamRecordToJson, type SerializedUpstreamRecord } from './serialize.ts';
import { MODEL_LISTING_FAILURE_MESSAGE } from '../../data-plane/models/shared.ts';
import { fetchUpstreamModelsCached } from '../../data-plane/providers/models-cache.ts';
Expand Down Expand Up @@ -357,20 +358,24 @@ const copilotConfigUserId = (config: unknown): number | null => {
return typeof config.user.id === 'number' && Number.isSafeInteger(config.user.id) ? config.user.id : null;
};

// The body's optional `proxy_fallback_list` is the operator's in-progress
// edit-form override, forwarded into every GitHub-side fetch so the device
// flow lands through the same proxy chain they're configuring.
export const copilotAuthPoll = async (c: CtxWithJson<typeof copilotAuthPollBody>) => {
try {
const { device_code: deviceCode } = c.req.valid('json');
const { device_code: deviceCode, proxy_fallback_list: proxyFallbackList } = c.req.valid('json');
const fetcher = await resolveControlPlaneFetcher({ override: proxyFallbackList });

const data = await pollGitHubDeviceFlow(deviceCode);
const data = await pollGitHubDeviceFlow(deviceCode, fetcher);

if (data.error === 'authorization_pending') return c.json({ status: 'pending' });
if (data.error === 'slow_down') return c.json({ status: 'slow_down', interval: data.interval });
if (data.error) return c.json({ status: 'error', error: data.error_description ?? data.error }, 400);

if (!data.access_token) return c.json({ status: 'error', error: 'Unknown response' }, 500);

const user = await fetchGitHubUser(data.access_token);
const accountType = await detectAccountType(data.access_token);
const user = await fetchGitHubUser(data.access_token, fetcher);
const accountType = await detectAccountType(data.access_token, fetcher);
if (!isCopilotAccountType(accountType)) {
return c.json({ status: 'error', error: 'Unsupported Copilot account type' }, 502);
}
Expand Down Expand Up @@ -401,7 +406,11 @@ export const copilotAuthPoll = async (c: CtxWithJson<typeof copilotAuthPollBody>
updatedAt: now,
flagOverrides: {},
disabledPublicModelIds: [],
proxyFallbackList: [],
// Persist the override on initial create so subsequent data-plane
// calls honor the same chain. Existing rows keep their stored
// list — the override above already routes the poll itself
// correctly without clobbering a prior persisted choice.
proxyFallbackList: proxyFallbackList !== undefined ? normalizeProxyFallbackList(proxyFallbackList) : [],
config,
state: null,
};
Expand Down Expand Up @@ -538,6 +547,9 @@ export const codexReimport = async (c: CtxWithJson<typeof codexReimportBody>) =>
return c.json(await serializeForResponse(next));
};

// The body carries an optional `proxy_fallback_list` override so a refresh
// fired from an unsaved edit-form uses the in-progress chain rather than
// the persisted one. See proxy-resolution.ts for the layered policy.
export const codexRefreshNow = async (c: CtxWithJson<typeof codexRefreshNowBody>) => {
const id = c.req.param('id');
if (!id) return c.json({ error: 'upstream id is required' }, 400);
Expand All @@ -558,12 +570,15 @@ export const codexRefreshNow = async (c: CtxWithJson<typeof codexRefreshNowBody>
return c.json({ error: `Codex upstream is ${account.state}; re-import to recover` }, 400);
}

const body = c.req.valid('json');
let fetcher;
try {
fetcher = await resolveControlPlaneFetcher({ override: body.proxy_fallback_list, upstreamId: id });
} catch (err) {
return c.json({ error: errorMessage(err) }, 400);
}

try {
// Thread the per-upstream proxy-aware fetcher so the operator-pressed
// Refresh button respects the same fallback chain as the data-plane hot
// path. Without this, a Codex upstream behind a corporate proxy would
// dial direct here and silently fail under restricted egress.
const fetcher = (await createPerRequestFetcherForAdmin())(id);
const tokens = await refreshCodexAccessToken(account.refresh_token, fetcher);
const now = new Date();
const nextAccount = {
Expand Down
Loading