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
23 changes: 23 additions & 0 deletions apps/api/src/llm-gateway/models/picker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ describe('connectedByokPickerModels', () => {
expect(catalogHas('anthropic', anthropic!.id.slice('anthropic/'.length))).toBe(true);
});

test('treats Bedrock bearer+region as connected (not only catalog env[0])', () => {
const models = connectedByokPickerModels(
new Set(['AWS_BEARER_TOKEN_BEDROCK', 'AWS_REGION']),
);
const bedrock = models.find((m) => m.provider === 'amazon-bedrock');
expect(bedrock).toBeTruthy();
expect(bedrock!.id.startsWith('amazon-bedrock/')).toBe(true);
expect(catalogHas('amazon-bedrock', bedrock!.id.slice('amazon-bedrock/'.length))).toBe(true);
});

test('no connected providers → no BYOK entries', () => {
expect(connectedByokPickerModels(new Set())).toEqual([]);
});
Expand Down Expand Up @@ -139,4 +149,17 @@ describe('projectPickerCatalog', () => {

expect(Object.keys(projectPickerCatalog(full, new Set(), []))).toEqual([]);
});

test('includes Bedrock models when bearer+region secrets are connected', () => {
const full = {
'amazon-bedrock/claude-a': { name: 'Claude A' },
'openai/gpt-a': { name: 'GPT A' },
};

expect(
projectPickerCatalog(full, new Set(['AWS_BEARER_TOKEN_BEDROCK', 'AWS_REGION']), []),
).toEqual({
'amazon-bedrock/claude-a': full['amazon-bedrock/claude-a'],
});
});
});
48 changes: 45 additions & 3 deletions apps/web/src/features/providers/provider-connect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ export interface ProviderConnectViewProps {
canWrite: boolean;
search: string;
onSearchChange: (value: string) => void;
/** True until focus — blocks silent email injection into the search box. */
searchReadOnly?: boolean;
onSearchFocus?: () => void;
/** Remove a stored key. The host confirms first — this only asks. */
onRemoveKey?: (providerId: string) => void;
/** Per-provider extra auth affordance. Only `openai` has one today. */
Expand Down Expand Up @@ -682,6 +685,8 @@ export function ProviderConnectView({
canWrite,
search,
onSearchChange,
searchReadOnly = false,
onSearchFocus,
subscriptionSlots,
accessSlots,
pooledSlots,
Expand All @@ -698,15 +703,35 @@ export function ProviderConnectView({
<Search />
</InputGroupSearchIcon>
<InputGroupSearchInput
type="text"
type="search"
// The count is the list's own size, stated where you would narrow
// it. Every one of those providers is rendered below.
placeholder={tI18nComplete('textbcace0f3244e', { value0: totalCount })}
// Password managers / Chrome form-fill treat this screen as a login
// form (API-key fields below are `type="password"`) and inject the
// account email into the first text input — this search. Same
// opt-outs as the key fields; `readOnly` until focus is the reliable
// Chrome stop for silent username injection.
name="provider-catalogue-search"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
data-1p-ignore=""
data-lpignore="true"
data-bwignore="true"
data-form-type="other"
readOnly={searchReadOnly}
onFocus={onSearchFocus}
value={search}
onChange={(event) => onSearchChange(event.target.value)}
/>
<InputGroupSearchClear onClick={() => onSearchChange('')} />
{/* Always show Clear when the field has text — silent form-fill can
populate without focus, and `peer-focus:opacity-100` alone left
users stuck with an email they could not dismiss. */}
<InputGroupSearchClear
className={search ? 'opacity-100' : undefined}
onClick={() => onSearchChange('')}
/>
</InputGroupSearch>

{/* The one sentence on the screen. With no Connect button, this is the
Expand Down Expand Up @@ -890,6 +915,21 @@ export function ProviderConnect({
const [savedValues, setSavedValues] = useState<Record<string, string>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [search, setSearch] = useState('');
// Chrome (and some extensions) treat this page as a login form because
// provider key fields are `type="password"`, then inject the account email
// into the first text input — this search — even when the user never typed
// it and has "autofill" off in the password-manager sense. Nothing in app
// code sets `search` to an email; rejecting email-shaped values and keeping
// the field read-only until focus stops that loop from sticking the UI on
// "No provider matches <email>".
const [searchEditable, setSearchEditable] = useState(false);
const setProviderSearch = (value: string) => {
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())) {
setSearch('');
return;
}
setSearch(value);
};
const [removeId, setRemoveId] = useState<string | null>(null);
// The connect we are waiting on. The VISIBLE pending flag is DERIVED from it
// below rather than cleared from inside an effect — that synchronous
Expand Down Expand Up @@ -1193,7 +1233,9 @@ export function ProviderConnect({
onRemoveKey={canWrite ? setRemoveId : undefined}
canWrite={canWrite}
search={search}
onSearchChange={setSearch}
onSearchChange={setProviderSearch}
searchReadOnly={!searchEditable}
onSearchFocus={() => setSearchEditable(true)}
subscriptionSlots={
pooledSecretsEnabled && accountId ? {
openai: <div className="space-y-2">
Expand Down
16 changes: 10 additions & 6 deletions scripts/dev-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ load_local_env() {
for _f in apps/api/.env apps/web/.env; do
_env="$("$DOTENVX" get --format eval -f "$ROOT_DIR/$_f" 2>/dev/null || true)"
if [[ -z "$_env" || "$_env" == *'="encrypted:'* ]]; then
echo "[dev] ⚠️ could not decrypt $_f — run 'dotenvx-armor login' (or restore its .env.keys)" >&2
if [[ -f "$ROOT_DIR/apps/api/.env.local" || -f "$ROOT_DIR/apps/web/.env.local" ]]; then
echo "[dev] skipping encrypted $_f (using .env.local — Dotenv Armor unused)" >&2
else
echo "[dev] ⚠️ could not decrypt $_f — run 'dotenvx-armor login' (or restore its .env.keys)" >&2
fi
else
set -a; eval "$_env"; set +a
fi
Expand Down Expand Up @@ -133,20 +137,20 @@ ensure_dev_tunnel() {
fi

# Non-cloud providers run on this machine — no public callback needed.
# Honor an explicit opt-out too.
if [[ "${KORTIX_DEV_TUNNEL:-auto}" == "0" || ( "$default_provider" != "daytona" && "$default_provider" != "platinum" ) ]]; then
# Honor an explicit opt-out too. E2B is cloud (same callback need as Daytona/Platinum).
if [[ "${KORTIX_DEV_TUNNEL:-auto}" == "0" || ( "$default_provider" != "daytona" && "$default_provider" != "platinum" && "$default_provider" != "e2b" ) ]]; then
export KORTIX_URL="$api_origin"
echo "[dev] Tunnel skipped — KORTIX_URL=$KORTIX_URL"
if [[ "$default_provider" == "daytona" ]]; then
echo "[dev] ⚠️ Default sandbox provider is Daytona (cloud) but the tunnel is off —"
if [[ "$default_provider" == "daytona" || "$default_provider" == "platinum" || "$default_provider" == "e2b" ]]; then
echo "[dev] ⚠️ Default sandbox provider is $default_provider (cloud) but the tunnel is off —"
echo "[dev] sessions will fail with 'OpenCode runtime is not ready' because the"
echo "[dev] sandbox cannot reach $api_origin. Unset KORTIX_DEV_TUNNEL to enable it."
fi
return 0
fi

if ! command -v cloudflared >/dev/null 2>&1; then
echo "[dev] ERROR: cloudflared is required for cloud (Daytona) sandboxes but was not found."
echo "[dev] ERROR: cloudflared is required for cloud (Daytona/Platinum/E2B) sandboxes but was not found."
echo "[dev] Cloud sandboxes can't reach localhost; they need a public KORTIX_URL."
echo "[dev] Install: brew install cloudflared"
echo "[dev] Or: KORTIX_DEV_TUNNEL=0 pnpm dev (skips the tunnel — cloud sandboxes won't reach localhost)"
Expand Down
Loading