feat(providers): add Devin OAuth and quota support
- Add Devin login to standard and simplified modes - Support credentials, model aliases, and exclusions - Display daily and weekly quotas with reset times - Validate callbacks and prevent stale login updates - Add translations, theme support, and integration tests
This commit is contained in:
1 parent
0725f6ed8a
commit
0c37f4d1bb
23 files changed
+470
-17
No files matched your search
@@ -205,7 +205,7 @@ pub(crate) fn resolve_model_alias_edit_source(
|
||||
if let ThinkingAliasSourceLocation::Oauth { channel, .. } = source.location {
|
||||
if let Some(model) = definitions
|
||||
.iter()
|
||||
.filter(|set| set.channel.key == channel)
|
||||
.filter(|set| set.channel.key == channel && set.channel.supports_reasoning)
|
||||
.flat_map(|set| &set.models)
|
||||
.find(|model| model.id.eq_ignore_ascii_case(&source.source.model))
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ pub(crate) struct OAuthAliasChannel {
|
||||
pub(crate) force_mapping: bool,
|
||||
}
|
||||
|
||||
pub(crate) const OAUTH_ALIAS_CHANNELS: [OAuthAliasChannel; 7] = [
|
||||
pub(crate) const OAUTH_ALIAS_CHANNELS: [OAuthAliasChannel; 8] = [
|
||||
OAuthAliasChannel {
|
||||
key: "vertex",
|
||||
provider: "Vertex OAuth",
|
||||
@@ -67,6 +67,15 @@ pub(crate) const OAUTH_ALIAS_CHANNELS: [OAuthAliasChannel; 7] = [
|
||||
supports_fast: false,
|
||||
force_mapping: false,
|
||||
},
|
||||
OAuthAliasChannel {
|
||||
key: "devin",
|
||||
provider: "Devin OAuth",
|
||||
kind: "devin-oauth",
|
||||
protocol: "interactions",
|
||||
supports_reasoning: false,
|
||||
supports_fast: false,
|
||||
force_mapping: false,
|
||||
},
|
||||
OAuthAliasChannel {
|
||||
key: "xai",
|
||||
provider: "xAI OAuth",
|
||||
@@ -126,6 +135,7 @@ pub(crate) fn normalize_oauth_alias_channel(value: &str) -> Option<&'static str>
|
||||
"claude" | "anthropic" => Some("claude"),
|
||||
"codex" => Some("codex"),
|
||||
"kimi" | "moonshot" => Some("kimi"),
|
||||
"devin" | "cognition" => Some("devin"),
|
||||
"xai" | "x-ai" | "grok" => Some("xai"),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -361,6 +361,7 @@ fn normalize_management_oauth_provider(provider: &str) -> Result<String, String>
|
||||
let key = match key.as_str() {
|
||||
"claude" | "anthropic" => "anthropic".to_string(),
|
||||
"anti-gravity" => "antigravity".to_string(),
|
||||
"cognition" => "devin".to_string(),
|
||||
"grok" | "x-ai" | "x.ai" => "xai".to_string(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
@@ -375,7 +376,7 @@ fn normalize_management_oauth_provider(provider: &str) -> Result<String, String>
|
||||
}
|
||||
|
||||
fn management_oauth_uses_webui_callback(provider_key: &str) -> bool {
|
||||
matches!(provider_key, "codex" | "anthropic" | "antigravity" | "xai")
|
||||
matches!(provider_key, "codex" | "anthropic" | "antigravity" | "xai" | "devin")
|
||||
}
|
||||
|
||||
async fn read_management_json<T>(response: reqwest::Response) -> Result<T, String>
|
||||
@@ -459,6 +460,15 @@ mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn devin_oauth_uses_the_management_callback_flow() {
|
||||
for provider in ["Devin", " cognition "] {
|
||||
let key = normalize_management_oauth_provider(provider).unwrap();
|
||||
assert_eq!(key, "devin");
|
||||
assert!(management_oauth_uses_webui_callback(&key));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_logs_follow_the_default_auth_directory() {
|
||||
let base_dir = PathBuf::from("test-base");
|
||||
|
||||
@@ -699,3 +699,25 @@ fn edit_model_alias_rejects_missing_and_ambiguous_aliases() {
|
||||
let content = "oauth-model-alias:\n codex:\n - name: model-a\n alias: shared\n claude:\n - name: model-b\n alias: shared\n";
|
||||
assert!(resolve_model_alias_edit_source(content, "shared", &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn devin_aliases_use_the_devin_channel_without_unsupported_overrides() {
|
||||
assert_eq!(normalize_oauth_alias_channel("cognition"), Some("devin"));
|
||||
let available_models = test_agent_models(&["devin/swe-2"]);
|
||||
let mut definitions = test_oauth_definition_set("devin", &["devin/swe-2"]);
|
||||
definitions.models[0].reasoning_levels = vec!["medium".to_string(), "high".to_string()];
|
||||
let definitions = vec![definitions];
|
||||
let sources = resolved_oauth_alias_sources("{}\n", &definitions, &available_models, AliasSourceCapability::Base).unwrap();
|
||||
assert_eq!(sources.len(), 1);
|
||||
assert_eq!(sources[0].source.kind, "devin-oauth");
|
||||
let rendered = add_model_alias_to_yaml("{}\n", &sources[0], "swe-2", "", false).unwrap();
|
||||
assert!(rendered.contains("devin:"), "{rendered}");
|
||||
assert!(rendered.contains("name: devin/swe-2"), "{rendered}");
|
||||
assert!(rendered.contains("alias: swe-2"), "{rendered}");
|
||||
assert!(!rendered.contains("payload:"), "{rendered}");
|
||||
let edited = model_alias_edit_context(&rendered, "swe-2", &definitions).unwrap();
|
||||
assert!(edited.source.reasoning_levels.is_empty());
|
||||
for capability in [AliasSourceCapability::Reasoning, AliasSourceCapability::Fast] {
|
||||
assert!(resolved_oauth_alias_sources("{}\n", &definitions, &available_models, capability).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Devin</title><path d="M2.033 9.867l2.554 1.483a.589.589 0 00.592 0l2.554-1.483.01-.008a.608.608 0 00.11-.084l.013-.015a.631.631 0 00.076-.1c.003-.005.008-.01.01-.016a.558.558 0 00.052-.125l.007-.028a.611.611 0 00.019-.14V7.868c0-.572.307-1.105.8-1.392a1.595 1.595 0 011.598 0l1.277.742a.54.54 0 00.129.053l.028.01c.044.01.088.015.133.016h.006l.013-.002a.587.587 0 00.27-.074l.011-.004 2.554-1.483a.596.596 0 00.297-.516V2.253a.595.595 0 00-.297-.516L12.293.257a.587.587 0 00-.591 0L9.148 1.737l-.01.01a.609.609 0 00-.109.083l-.014.015a.632.632 0 00-.076.1c-.003.005-.008.01-.01.016a.57.57 0 00-.052.124l-.007.028a.612.612 0 00-.018.14v1.483c0 .572-.307 1.105-.8 1.393a1.597 1.597 0 01-1.599 0l-1.276-.742a.603.603 0 00-.13-.053l-.028-.008a.658.658 0 00-.133-.018h-.02a.57.57 0 00-.269.074c-.003.002-.008.002-.012.005L2.033 5.872a.596.596 0 00-.297.515v2.966c0 .213.113.41.297.515z"></path><path d="M15.943 10.607a1.596 1.596 0 011.599 0l1.276.74c.041.025.085.04.13.055l.028.008c.043.01.088.016.133.018h.005c.005 0 .01-.002.014-.003a.474.474 0 00.122-.016l.021-.005a.616.616 0 00.126-.052c.004-.002.009-.002.013-.005l2.554-1.482a.597.597 0 00.297-.516V6.383a.596.596 0 00-.297-.515l-2.552-1.483a.587.587 0 00-.592 0l-2.553 1.482-.011.008a.61.61 0 00-.108.084l-.014.016a.637.637 0 00-.076.1c-.003.005-.008.01-.01.016a.57.57 0 00-.052.124l-.007.029a.612.612 0 00-.018.14v1.482c0 .572-.307 1.105-.8 1.393a1.597 1.597 0 01-1.599 0l-1.276-.742a.584.584 0 00-.13-.053l-.028-.008a.62.62 0 00-.133-.018h-.02a.587.587 0 00-.269.074l-.012.004L9.15 10a.596.596 0 00-.296.516v2.966c0 .212.112.409.296.515l2.554 1.483s.008.002.012.005c.04.022.082.04.126.052l.02.004a.57.57 0 00.123.017l.014.002h.006c.054 0 .108-.01.16-.025a.587.587 0 00.13-.054l1.277-.741a1.597 1.597 0 012.398 1.392v1.482c0 .049.007.095.019.14l.007.028a.619.619 0 00.051.125c.004.006.008.01.01.016a.6.6 0 00.076.1l.014.015c.033.032.069.06.108.084.004.002.006.006.011.008l2.554 1.483a.59.59 0 00.593 0l2.554-1.483a.597.597 0 00.296-.516v-2.965a.595.595 0 00-.296-.516l-2.554-1.483s-.008-.002-.012-.005a.54.54 0 00-.126-.051c-.007-.003-.013-.003-.02-.005a.635.635 0 00-.125-.017h-.018a.557.557 0 00-.16.026.588.588 0 00-.13.053l-1.276.742a1.595 1.595 0 01-1.598 0 1.615 1.615 0 010-2.785l-.005-.001z"></path><path d="M14.848 18.265l-2.554-1.482-.012-.005a.526.526 0 00-.126-.052c-.007-.002-.014-.002-.02-.005a.64.64 0 00-.124-.017h-.02a.56.56 0 00-.16.026.588.588 0 00-.13.053l-1.276.742a1.594 1.594 0 01-1.598 0c-.493-.286-.8-.82-.8-1.393V14.65a.563.563 0 00-.018-.14l-.008-.028a.604.604 0 00-.051-.124l-.01-.017a.603.603 0 00-.076-.1l-.014-.015a.596.596 0 00-.109-.084c-.003-.002-.005-.006-.01-.008L5.178 12.65a.587.587 0 00-.591 0l-2.554 1.483a.596.596 0 00-.297.516v2.965c0 .213.113.41.297.516l2.554 1.483.012.004a.618.618 0 00.267.074l.016.002h.007a.55.55 0 00.16-.026.584.584 0 00.129-.053l1.277-.742a1.597 1.597 0 012.398 1.393v1.482c0 .05.007.095.019.14l.007.028c.013.044.03.085.051.125l.01.016c.022.036.047.07.076.1l.014.015c.032.032.069.06.109.084l.01.008 2.554 1.483a.587.587 0 00.593 0l2.554-1.483a.596.596 0 00.296-.515v-2.966a.596.596 0 00-.296-.516h-.002z"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -27,6 +27,7 @@ export const easyModeZhCN = {
|
||||
"easyMode.oauth.providerDesc.antigravity": "Antigravity 账号授权登录",
|
||||
"easyMode.oauth.providerDesc.kimi": "Moonshot / Kimi 账号授权登录",
|
||||
"easyMode.oauth.providerDesc.xai": "xAI / Grok 账号授权登录",
|
||||
"easyMode.oauth.providerDesc.devin": "Devin / Cognition 账号授权登录",
|
||||
"easyMode.api.platformsConnected": "已接入 {count} 个平台",
|
||||
"easyMode.api.supportedPlatforms": "支持 OpenAI、Anthropic、Codex、Gemini、DeepSeek",
|
||||
"easyMode.api.platformName.openai": "OpenAI 格式",
|
||||
@@ -108,6 +109,7 @@ export const easyModeEn: Record<keyof typeof easyModeZhCN, string> = {
|
||||
"easyMode.oauth.providerDesc.antigravity": "Antigravity authorization sign-in",
|
||||
"easyMode.oauth.providerDesc.kimi": "Moonshot / Kimi authorization sign-in",
|
||||
"easyMode.oauth.providerDesc.xai": "xAI / Grok authorization sign-in",
|
||||
"easyMode.oauth.providerDesc.devin": "Devin / Cognition authorization sign-in",
|
||||
"easyMode.api.platformsConnected": "{count} platform(s) connected",
|
||||
"easyMode.api.supportedPlatforms": "Supports OpenAI, Anthropic, Codex, Gemini, DeepSeek",
|
||||
"easyMode.api.platformName.openai": "OpenAI Format",
|
||||
@@ -189,6 +191,7 @@ export const easyModeJa: Record<keyof typeof easyModeZhCN, string> = {
|
||||
"easyMode.oauth.providerDesc.antigravity": "Antigravity アカウント認証ログイン",
|
||||
"easyMode.oauth.providerDesc.kimi": "Moonshot / Kimi アカウント認証ログイン",
|
||||
"easyMode.oauth.providerDesc.xai": "xAI / Grok アカウント認証ログイン",
|
||||
"easyMode.oauth.providerDesc.devin": "Devin / Cognition アカウント認証ログイン",
|
||||
"easyMode.api.platformsConnected": "{count} 件のプラットフォームを接続済み",
|
||||
"easyMode.api.supportedPlatforms": "OpenAI、Anthropic、Codex、Gemini、DeepSeek に対応",
|
||||
"easyMode.api.platformName.openai": "OpenAI 形式",
|
||||
|
||||
@@ -326,6 +326,9 @@ export const jaOverrides = {
|
||||
'quota.confirm.available': '現在利用可能:{count} 回',
|
||||
'quota.confirm.expiry': '最も早い有効期限:{time}',
|
||||
'quota.confirm.warning': '手動リセットを 1 回消費します。「リセットを実行」をクリックした場合のみ実行します。キャンセルや閉じる操作では消費しません。',
|
||||
'oauth.devinHint': 'ブラウザーで Devin を認証してください。リダイレクトに失敗した場合は、今回のログインの完全なコールバック URL を貼り付けてください。',
|
||||
'oauth.devinStateMismatch': 'コールバックの state が今回の Devin ログインと一致しません。最新の認証リンクでログインしてください。',
|
||||
'quota.service.daily': '日次クォータ',
|
||||
'oauth.title': 'OAuth ログイン',
|
||||
'oauth.tabs.label': 'OAuth 管理ページ',
|
||||
'oauth.loginSuccess': '{provider} へのログインに成功しました',
|
||||
|
||||
@@ -313,6 +313,9 @@ export const en: Record<MessageKey, string> = {
|
||||
'quota.confirm.available': 'Currently available: {count}',
|
||||
'quota.confirm.expiry': 'Earliest expiry: {time}',
|
||||
'quota.confirm.warning': 'This consumes one manual reset credit. It runs only after Confirm Reset; canceling or closing does not consume a credit.',
|
||||
'oauth.devinHint': 'Authorize Devin in your browser. If the redirect fails, paste the full callback URL from this login attempt.',
|
||||
'oauth.devinStateMismatch': 'The callback state does not match this Devin login. Complete sign-in using the latest authorization link.',
|
||||
'quota.service.daily': 'Daily quota',
|
||||
'oauth.title': 'OAuth Sign-In',
|
||||
'oauth.tabs.label': 'OAuth management sections',
|
||||
'oauth.loginSuccess': '{provider} sign-in succeeded',
|
||||
|
||||
@@ -312,6 +312,9 @@ export const zhCN = {
|
||||
'quota.confirm.available': '当前可用:{count} 次',
|
||||
'quota.confirm.expiry': '最早过期:{time}',
|
||||
'quota.confirm.warning': '将消耗 1 次主动重置机会。只有点击“确认重置”才会执行,取消或关闭不会消耗次数。',
|
||||
'oauth.devinHint': '在浏览器中完成 Devin 授权;若跳转失败,请粘贴本次登录的完整回调 URL。',
|
||||
'oauth.devinStateMismatch': '回调 state 与本次 Devin 登录不匹配,请使用最新授权链接完成登录。',
|
||||
'quota.service.daily': '每日额度',
|
||||
'oauth.title': 'OAuth 登录',
|
||||
'oauth.tabs.label': 'OAuth 管理子页面',
|
||||
'oauth.loginSuccess': '{provider} 登录成功',
|
||||
|
||||
@@ -22,6 +22,7 @@ import claudeIcon from '../assets/icons/claude.svg';
|
||||
import codexIcon from '../assets/icons/codex.svg';
|
||||
import geminiIcon from '../assets/icons/gemini.svg';
|
||||
import grokIcon from '../assets/icons/grok.svg';
|
||||
import devinIcon from '../assets/icons/devin.svg';
|
||||
import kimiIcon from '../assets/icons/kimi-light.svg';
|
||||
import vertexIcon from '../assets/icons/vertex.svg';
|
||||
import {
|
||||
@@ -88,6 +89,7 @@ const providerIcons: Record<string, string> = {
|
||||
kimi: kimiIcon,
|
||||
vertex: vertexIcon,
|
||||
xai: grokIcon,
|
||||
devin: devinIcon,
|
||||
};
|
||||
|
||||
const providerName = (file: AuthFile) => {
|
||||
@@ -95,11 +97,13 @@ const providerName = (file: AuthFile) => {
|
||||
if (value === 'anthropic') return 'Claude';
|
||||
if (value === 'anti-gravity') return 'Antigravity';
|
||||
if (value === 'xai') return 'xAI';
|
||||
if (value === 'cognition') return 'Devin';
|
||||
return value ? value.charAt(0).toUpperCase() + value.slice(1) : translate(getCurrentLocale(), 'authFiles.unknownProvider');
|
||||
};
|
||||
|
||||
const providerKey = (file: AuthFile) => {
|
||||
const value = readString(file, 'provider', 'type', 'account_type').toLowerCase();
|
||||
if (value === 'cognition') return 'devin';
|
||||
return value === 'anthropic' ? 'claude' : value === 'anti-gravity' ? 'antigravity' : value;
|
||||
};
|
||||
|
||||
@@ -531,7 +535,7 @@ export function AuthFileManagementPage() {
|
||||
const priority = parseAuthFilePriority(file.priority) ?? 0;
|
||||
return (
|
||||
<article className={`real-auth-file-row ${disabled ? 'disabled' : ''}`} key={`${name}-${readString(file, 'auth_index', 'authIndex')}`}>
|
||||
<img src={icon} alt="" className="provider-logo" />
|
||||
<img src={icon} alt="" className={providerKey(file) === 'devin' ? 'provider-logo devin-logo' : 'provider-logo'} />
|
||||
<div className="auth-file-main">
|
||||
<div className="auth-file-title"><strong title={name}>{name}</strong><span className={`state-pill ${disabled ? 'error' : readBoolean(file, 'unavailable') ? 'error' : 'success'}`} title={readString(file, 'status_message', 'statusMessage') || undefined}>{statusText(file)}</span></div>
|
||||
<span>{providerName(file)}{readString(file, 'email', 'account', 'label') ? ` · ${readString(file, 'email', 'account', 'label')}` : ''}</span>
|
||||
|
||||
@@ -45,6 +45,7 @@ import claudeIcon from "../assets/icons/claude.svg";
|
||||
import antigravityIcon from "../assets/icons/antigravity.svg";
|
||||
import kimiIcon from "../assets/icons/kimi-light.svg";
|
||||
import grokIcon from "../assets/icons/grok.svg";
|
||||
import devinIcon from "../assets/icons/devin.svg";
|
||||
import openaiIcon from "../assets/icons/openai-light.svg";
|
||||
import deepseekIcon from "../assets/icons/deepseek.svg";
|
||||
import geminiIcon from "../assets/icons/gemini.svg";
|
||||
@@ -52,7 +53,7 @@ import geminiIcon from "../assets/icons/gemini.svg";
|
||||
type AuthMethod = "oauth" | "api";
|
||||
type SetupStep = 1 | 2;
|
||||
|
||||
type OAuthProviderId = "codex" | "claude" | "antigravity" | "kimi" | "xai";
|
||||
type OAuthProviderId = "codex" | "claude" | "antigravity" | "kimi" | "xai" | "devin";
|
||||
|
||||
type OAuthProviderInfo = {
|
||||
id: OAuthProviderId;
|
||||
@@ -67,6 +68,7 @@ const oauthProviders: OAuthProviderInfo[] = [
|
||||
{ id: "antigravity", name: "Antigravity OAuth", icon: antigravityIcon, descriptionKey: "easyMode.oauth.providerDesc.antigravity" },
|
||||
{ id: "kimi", name: "Kimi OAuth", icon: kimiIcon, descriptionKey: "easyMode.oauth.providerDesc.kimi" },
|
||||
{ id: "xai", name: "xAI OAuth", icon: grokIcon, descriptionKey: "easyMode.oauth.providerDesc.xai" },
|
||||
{ id: "devin", name: "Devin OAuth", icon: devinIcon, descriptionKey: "easyMode.oauth.providerDesc.devin" },
|
||||
];
|
||||
|
||||
type ApiSection = "openai-compatibility" | "deepseek" | "claude" | "gemini" | "codex";
|
||||
@@ -212,7 +214,7 @@ export function EasyModePage({
|
||||
const norm = providerId === "claude" ? "claude" : providerId === "codex" ? "codex" : providerId;
|
||||
return authFiles.some((f) => {
|
||||
const p = readString(f, "provider", "type").toLowerCase();
|
||||
return p.includes(norm) || (norm === "codex" && p.includes("openai")) || (norm === "claude" && p.includes("anthropic"));
|
||||
return (norm === "devin" && p === "cognition") || p.includes(norm) || (norm === "codex" && p.includes("openai")) || (norm === "claude" && p.includes("anthropic"));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -833,7 +835,7 @@ export function EasyModePage({
|
||||
>
|
||||
<div className="simple-mode-provider-card-head">
|
||||
<div className="simple-mode-provider-logo">
|
||||
<img src={provider.icon} alt="" />
|
||||
<img src={provider.icon} alt="" className={provider.id === "devin" ? "devin-logo" : undefined} />
|
||||
</div>
|
||||
<div className="simple-mode-provider-copy">
|
||||
<strong>{provider.name}</strong>
|
||||
|
||||
@@ -12,6 +12,7 @@ import antigravityIcon from '../assets/icons/antigravity.svg';
|
||||
import claudeIcon from '../assets/icons/claude.svg';
|
||||
import codexIcon from '../assets/icons/codex.svg';
|
||||
import grokIcon from '../assets/icons/grok.svg';
|
||||
import devinIcon from '../assets/icons/devin.svg';
|
||||
import kimiIcon from '../assets/icons/kimi-light.svg';
|
||||
import { useI18n } from '../i18n';
|
||||
import { FloatingNotice, useAppNotice } from '../appNotice';
|
||||
@@ -28,8 +29,9 @@ import {
|
||||
} from '../services/oauthLoginState';
|
||||
import { AuthFileManagementPage } from './AuthFileManagementPage';
|
||||
import { QuotaPage } from './QuotaPage';
|
||||
import { validateDevinCallback } from '../services/devinOAuth';
|
||||
|
||||
type OAuthProviderId = 'codex' | 'claude' | 'antigravity' | 'kimi' | 'xai';
|
||||
type OAuthProviderId = 'codex' | 'claude' | 'antigravity' | 'kimi' | 'xai' | 'devin';
|
||||
type OAuthFlowStatus = 'idle' | 'waiting' | 'success' | 'error';
|
||||
|
||||
type OAuthProviderState = {
|
||||
@@ -68,6 +70,7 @@ const oauthProviders = [
|
||||
{ id: 'antigravity' as const, name: 'Antigravity OAuth', icon: antigravityIcon },
|
||||
{ id: 'kimi' as const, name: 'Kimi OAuth', icon: kimiIcon },
|
||||
{ id: 'xai' as const, name: 'xAI OAuth', icon: grokIcon },
|
||||
{ id: 'devin' as const, name: 'Devin OAuth', icon: devinIcon },
|
||||
];
|
||||
|
||||
const OAUTH_CALLBACK_SUPPORTED = new Set<OAuthProviderId>([
|
||||
@@ -75,6 +78,7 @@ const OAUTH_CALLBACK_SUPPORTED = new Set<OAuthProviderId>([
|
||||
'claude',
|
||||
'antigravity',
|
||||
'xai',
|
||||
'devin',
|
||||
]);
|
||||
const XAI_CALLBACK_URL = 'http://127.0.0.1:56121/callback';
|
||||
const OAUTH_POLL_INTERVAL_MS = 3000;
|
||||
@@ -165,6 +169,7 @@ export function OAuthLoginPage() {
|
||||
const [selectedBrowser, setSelectedBrowser] = useState(loadOAuthBrowserPreference);
|
||||
const pollingTimers = useRef<Partial<Record<OAuthProviderId, number>>>({});
|
||||
const pollingRequests = useRef<Partial<Record<OAuthProviderId, boolean>>>({});
|
||||
const pollingSessions = useRef<Partial<Record<OAuthProviderId, string>>>({});
|
||||
const credentialSnapshots = useRef<Partial<Record<OAuthProviderId, AuthFileSnapshot>>>({});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -212,6 +217,7 @@ export function OAuthLoginPage() {
|
||||
const clearPollingTimer = useCallback((provider: OAuthProviderId) => {
|
||||
const timer = pollingTimers.current[provider];
|
||||
if (timer !== undefined) window.clearInterval(timer);
|
||||
delete pollingSessions.current[provider];
|
||||
delete pollingTimers.current[provider];
|
||||
delete pollingRequests.current[provider];
|
||||
}, []);
|
||||
@@ -256,11 +262,14 @@ export function OAuthLoginPage() {
|
||||
const startPolling = useCallback(
|
||||
(provider: OAuthProviderId, state: string) => {
|
||||
clearPollingTimer(provider);
|
||||
pollingSessions.current[provider] = state;
|
||||
const isCurrent = () => pollingSessions.current[provider] === state;
|
||||
const checkStatus = async () => {
|
||||
if (pollingRequests.current[provider]) return;
|
||||
pollingRequests.current[provider] = true;
|
||||
try {
|
||||
const result = await invoke<OAuthStatusResult>('get_oauth_status', { state });
|
||||
if (!isCurrent()) return;
|
||||
const status = (result.status || '').toLowerCase();
|
||||
if (status === 'ok') {
|
||||
let priorityError = '';
|
||||
@@ -269,6 +278,7 @@ export function OAuthLoginPage() {
|
||||
} catch (error) {
|
||||
priorityError = String(error);
|
||||
}
|
||||
if (!isCurrent()) return;
|
||||
completeProviderAuth(provider);
|
||||
showNotice(
|
||||
priorityError
|
||||
@@ -292,6 +302,7 @@ export function OAuthLoginPage() {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return;
|
||||
updateProviderState(provider, {
|
||||
status: 'error',
|
||||
error: String(error),
|
||||
@@ -300,7 +311,7 @@ export function OAuthLoginPage() {
|
||||
clearPollingTimer(provider);
|
||||
showNotice(String(error), 'error');
|
||||
} finally {
|
||||
delete pollingRequests.current[provider];
|
||||
if (isCurrent()) delete pollingRequests.current[provider];
|
||||
}
|
||||
};
|
||||
pollingTimers.current[provider] = window.setInterval(
|
||||
@@ -313,6 +324,7 @@ export function OAuthLoginPage() {
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pollingSessions.current = {};
|
||||
Object.values(pollingTimers.current).forEach((timer) => {
|
||||
if (timer !== undefined) window.clearInterval(timer);
|
||||
});
|
||||
@@ -386,6 +398,12 @@ export function OAuthLoginPage() {
|
||||
await managementApi.delete('/oauth-session', { query: { state: currentState } });
|
||||
}
|
||||
} catch (error) {
|
||||
if (provider === 'devin') {
|
||||
updateProviderState(provider, { refreshing: false });
|
||||
if (currentState) startPolling(provider, currentState);
|
||||
showNotice(String(error), 'error');
|
||||
return;
|
||||
}
|
||||
console.warn('Failed to cancel the previous OAuth session before refreshing', error);
|
||||
}
|
||||
try {
|
||||
@@ -428,6 +446,13 @@ export function OAuthLoginPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'devin') {
|
||||
const error = validateDevinCallback(callbackInput, current?.state);
|
||||
if (error) {
|
||||
showNotice(t(error === 'state_mismatch' ? 'oauth.devinStateMismatch' : 'oauth.invalidCallback'), 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const redirectUrl = resolveCallbackUrl(provider, callbackInput, current?.state);
|
||||
if (!redirectUrl) {
|
||||
showNotice(
|
||||
@@ -499,7 +524,7 @@ export function OAuthLoginPage() {
|
||||
return (
|
||||
<section className="panel oauth-card" key={provider.id}>
|
||||
<div className="provider-title-row">
|
||||
<img src={provider.icon} alt="" className="provider-logo" />
|
||||
<img src={provider.icon} alt="" className={provider.id === 'devin' ? 'provider-logo devin-logo' : 'provider-logo'} />
|
||||
<div>
|
||||
<h2>{provider.name}</h2>
|
||||
{shouldShowOAuthLoginStatus(state.status) ? (
|
||||
@@ -509,7 +534,7 @@ export function OAuthLoginPage() {
|
||||
</div>
|
||||
|
||||
<div className="oauth-card-body">
|
||||
<p className="oauth-hint">{t('oauth.hint')}</p>
|
||||
<p className="oauth-hint">{t(provider.id === 'devin' ? 'oauth.devinHint' : 'oauth.hint')}</p>
|
||||
{state.url ? (
|
||||
<div className="oauth-auth-url-box">
|
||||
<div className="oauth-auth-url-label">{t('oauth.authorizationLink')}</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import antigravityIcon from '../assets/icons/antigravity.svg';
|
||||
import claudeIcon from '../assets/icons/claude.svg';
|
||||
import codexIcon from '../assets/icons/codex.svg';
|
||||
import grokIcon from '../assets/icons/grok.svg';
|
||||
import devinIcon from '../assets/icons/devin.svg';
|
||||
import kimiIcon from '../assets/icons/kimi-light.svg';
|
||||
import { managementApi, readBoolean, responseList } from '../services/managementApi';
|
||||
import { formatQuotaReset, useQuotaClock } from '../services/quotaTime';
|
||||
@@ -38,10 +39,11 @@ const providerMeta: Record<QuotaProvider, { label: string; icon: string }> = {
|
||||
codex: { label: 'Codex', icon: codexIcon },
|
||||
kimi: { label: 'Kimi', icon: kimiIcon },
|
||||
xai: { label: 'xAI', icon: grokIcon },
|
||||
devin: { label: 'Devin', icon: devinIcon },
|
||||
antigravity: { label: 'Antigravity', icon: antigravityIcon },
|
||||
};
|
||||
|
||||
const providerOrder: QuotaProvider[] = ['claude', 'antigravity', 'codex', 'xai', 'kimi'];
|
||||
const providerOrder: QuotaProvider[] = ['claude', 'antigravity', 'codex', 'xai', 'kimi', 'devin'];
|
||||
const REFRESH_CONCURRENCY = 4;
|
||||
|
||||
export function QuotaPage() {
|
||||
@@ -177,7 +179,7 @@ export function QuotaPage() {
|
||||
<div className="quota-group-list">
|
||||
{grouped.map(([provider, items]) => (
|
||||
<section className="quota-provider-group" key={provider}>
|
||||
<div className="quota-group-heading"><div><img src={providerMeta[provider].icon} alt="" className="provider-logo" /><h2>{providerMeta[provider].label}</h2></div><span>{t(items.length === 1 ? 'quota.credentials.one' : 'quota.credentials.other', { count: items.length })}</span></div>
|
||||
<div className="quota-group-heading"><div><img src={providerMeta[provider].icon} alt="" className={provider === 'devin' ? 'provider-logo devin-logo' : 'provider-logo'} /><h2>{providerMeta[provider].label}</h2></div><span>{t(items.length === 1 ? 'quota.credentials.one' : 'quota.credentials.other', { count: items.length })}</span></div>
|
||||
<div className="real-quota-grid">{items.map(({ file, quota }) => <QuotaCard key={quotaKey(file)} file={file} quota={quota} onRefresh={() => void refreshOne(file)} onReset={provider === 'codex' ? () => void resetCodexQuota(file, quota) : undefined} />)}</div>
|
||||
</section>
|
||||
))}
|
||||
@@ -216,6 +218,7 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile;
|
||||
{quota.subscriptionActiveUntil ? <span>{t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })}</span> : null}
|
||||
<MessageNotice message={quota.resetCreditsError ? name + ': ' + t('quota.resetCreditsWarning', { error: quota.resetCreditsError }) : null} />
|
||||
</div> : null}
|
||||
{quota.status === 'success' && provider === 'devin' && quota.subscriptionActiveUntil ? <div className="quota-reset-credit-summary"><span>{t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })}</span></div> : null}
|
||||
{quota.status === 'success' ? <div className="quota-row-list">{quota.rows.map((row, index) => {
|
||||
const reset = formatQuotaReset(row.resetAtMs, row.reset, locale, now);
|
||||
return <div className="real-quota-row" key={`${row.label}-${index}`}>
|
||||
|
||||
@@ -168,6 +168,7 @@ export const thinkingAliasSourceKindLabel = (kind: string) => {
|
||||
if (kind === 'vertex-oauth') return 'Vertex OAuth';
|
||||
if (kind === 'kimi-oauth') return 'Kimi OAuth';
|
||||
if (kind === 'xai-oauth') return 'xAI OAuth';
|
||||
if (kind === 'devin-oauth') return 'Devin OAuth';
|
||||
if (kind === 'codex-api') return 'Codex API';
|
||||
if (kind === 'claude-api') return 'Claude API';
|
||||
if (kind === 'gemini-api') return 'Gemini API';
|
||||
|
||||
@@ -29,6 +29,7 @@ export const normalizeAuthFilePriorityInput = (value: string): number | null =>
|
||||
|
||||
const normalizeOAuthProvider = (value: string) => {
|
||||
const provider = value.trim().toLowerCase();
|
||||
if (provider === 'cognition') return 'devin';
|
||||
if (provider === 'anthropic') return 'claude';
|
||||
if (provider === 'openai') return 'codex';
|
||||
return provider;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export const validateDevinCallback = (
|
||||
input: string,
|
||||
expectedState?: string,
|
||||
): 'invalid' | 'state_mismatch' | undefined => {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(input.trim());
|
||||
} catch {
|
||||
return 'invalid';
|
||||
}
|
||||
const params = url.searchParams;
|
||||
if (!['http:', 'https:'].includes(url.protocol)
|
||||
|| params.getAll('state').length !== 1
|
||||
|| !params.get('state')?.trim()
|
||||
|| !['code', 'error', 'error_description'].some((key) => params.get(key)?.trim())) {
|
||||
return 'invalid';
|
||||
}
|
||||
if (!expectedState || params.get('state') !== expectedState) return 'state_mismatch';
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { isRecord } from './managementApi';
|
||||
|
||||
export const DEVIN_QUOTA_URL = 'https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus';
|
||||
export const DEVIN_QUOTA_HEADERS = {
|
||||
'Content-Type': 'application/json',
|
||||
'Connect-Protocol-Version': '1',
|
||||
};
|
||||
export const DEVIN_QUOTA_DATA = JSON.stringify({
|
||||
metadata: {
|
||||
ideName: 'chisel',
|
||||
ideVersion: '3000.10.21',
|
||||
apiKey: '$TOKEN$',
|
||||
locale: 'en',
|
||||
os: 'darwin',
|
||||
extensionVersion: '3000.10.21',
|
||||
clientName: 'chisel',
|
||||
},
|
||||
});
|
||||
|
||||
const percentValue = (value: unknown): number | null => {
|
||||
if (typeof value !== 'number' && typeof value !== 'string') return null;
|
||||
if (typeof value === 'string' && !/^\d+(?:\.\d+)?$/.test(value.trim())) return null;
|
||||
const percent = Number(value);
|
||||
return Number.isFinite(percent) && percent >= 0 && percent <= 100 ? percent : null;
|
||||
};
|
||||
|
||||
const unixInstant = (value: unknown): number | undefined => {
|
||||
if (typeof value !== 'number' && typeof value !== 'string') return undefined;
|
||||
if (typeof value === 'string' && !/^\d+$/.test(value.trim())) return undefined;
|
||||
const seconds = Number(value);
|
||||
const ms = seconds * 1000;
|
||||
return Number.isSafeInteger(seconds) && ms > 0 && Number.isFinite(new Date(ms).getTime())
|
||||
? ms : undefined;
|
||||
};
|
||||
|
||||
export const readDevinQuota = (payload: unknown) => {
|
||||
if (typeof payload === 'string') {
|
||||
try {
|
||||
payload = JSON.parse(payload);
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
}
|
||||
const userStatus = isRecord(payload) && isRecord(payload.userStatus) ? payload.userStatus : {};
|
||||
const planStatus = isRecord(userStatus.planStatus) ? userStatus.planStatus : {};
|
||||
const planInfo = isRecord(planStatus.planInfo) ? planStatus.planInfo : {};
|
||||
const windows = (['daily', 'weekly'] as const).map((id) => ({
|
||||
id,
|
||||
remainingPercent: percentValue(planStatus[id + 'QuotaRemainingPercent']),
|
||||
resetAtMs: unixInstant(planStatus[id + 'QuotaResetAtUnix']),
|
||||
}));
|
||||
const observed = windows.some((window) => window.remainingPercent !== null || window.resetAtMs !== undefined);
|
||||
const planEnd = planStatus.planEnd;
|
||||
return {
|
||||
windows: observed ? windows : [],
|
||||
plan: typeof planInfo.planName === 'string' ? planInfo.planName.trim() || undefined : undefined,
|
||||
subscriptionActiveUntil: typeof planEnd === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(planEnd)
|
||||
&& Number.isFinite(Date.parse(planEnd)) ? planEnd : undefined,
|
||||
};
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
readString,
|
||||
} from './managementApi';
|
||||
import { authFileName } from './authFiles';
|
||||
import { DEVIN_QUOTA_DATA, DEVIN_QUOTA_HEADERS, DEVIN_QUOTA_URL, readDevinQuota } from './devinQuota';
|
||||
import { antigravityProjectFor, codexMetadataFor, isPaidXaiFile } from './quotaMetadata';
|
||||
import { quotaResetFor, quotaResetInstant } from './quotaTime';
|
||||
import { getCurrentLocale, translate, type AppLocale } from '../i18n';
|
||||
@@ -17,7 +18,7 @@ const quotaText = (
|
||||
) => translate(getCurrentLocale(), key, variables);
|
||||
|
||||
export type AuthFile = Record<string, unknown>;
|
||||
export type QuotaProvider = 'claude' | 'codex' | 'kimi' | 'xai' | 'antigravity';
|
||||
export type QuotaProvider = 'claude' | 'codex' | 'kimi' | 'xai' | 'antigravity' | 'devin';
|
||||
export type QuotaStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||
export type QuotaRow = {
|
||||
label: string;
|
||||
@@ -45,6 +46,7 @@ export type QuotaState = {
|
||||
export const idleQuota = (): QuotaState => ({ status: 'idle', rows: [] });
|
||||
|
||||
const endpointByProvider: Record<QuotaProvider, string> = {
|
||||
devin: DEVIN_QUOTA_URL,
|
||||
claude: 'https://api.anthropic.com/api/oauth/usage',
|
||||
codex: 'https://chatgpt.com/backend-api/wham/usage',
|
||||
kimi: 'https://api.kimi.com/coding/v1/usages',
|
||||
@@ -62,6 +64,7 @@ const ANTIGRAVITY_CODE_ASSIST_URL =
|
||||
'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist';
|
||||
|
||||
const headersByProvider: Record<QuotaProvider, Record<string, string>> = {
|
||||
devin: DEVIN_QUOTA_HEADERS,
|
||||
claude: {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'Content-Type': 'application/json',
|
||||
@@ -90,9 +93,10 @@ const headersByProvider: Record<QuotaProvider, Record<string, string>> = {
|
||||
export const providerForFile = (file: AuthFile): QuotaProvider | null => {
|
||||
const value = readString(file, 'provider', 'type', 'account_type').toLowerCase().replace(/_/g, '-');
|
||||
if (value === 'x-ai' || value === 'grok') return 'xai';
|
||||
if (value === 'cognition') return 'devin';
|
||||
if (value === 'anthropic') return 'claude';
|
||||
if (value === 'anti-gravity') return 'antigravity';
|
||||
return ['claude', 'codex', 'kimi', 'xai', 'antigravity'].includes(value)
|
||||
return ['claude', 'codex', 'kimi', 'xai', 'antigravity', 'devin'].includes(value)
|
||||
? (value as QuotaProvider)
|
||||
: null;
|
||||
};
|
||||
@@ -337,6 +341,11 @@ export const codexResetCreditDetailsFor = (
|
||||
};
|
||||
|
||||
export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRow[] => {
|
||||
if (provider === 'devin') return readDevinQuota(payload).windows.map((window) => ({
|
||||
label: quotaText(window.id === 'daily' ? 'quota.service.daily' : 'quota.service.weekly'),
|
||||
remainingPercent: window.remainingPercent,
|
||||
resetAtMs: window.resetAtMs,
|
||||
}));
|
||||
const value = parseBody(payload);
|
||||
if (!isRecord(value)) return [];
|
||||
|
||||
@@ -754,6 +763,10 @@ async function callUpstreamQuota(
|
||||
): Promise<unknown> {
|
||||
const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex);
|
||||
if (!authIndex) throw new Error(quotaText('quota.service.error.missingAuthIndex'));
|
||||
if (provider === 'devin') {
|
||||
if (!readString(file, 'name')) throw new Error(quotaText('quota.service.error.missingAuthIndex'));
|
||||
return requestQuotaPayload(authIndex, DEVIN_QUOTA_URL, DEVIN_QUOTA_HEADERS, 'POST', DEVIN_QUOTA_DATA, undefined, responseClock);
|
||||
}
|
||||
const header = { ...headersByProvider[provider] };
|
||||
if (provider === 'codex') {
|
||||
const accountId = resolvedCodexAccountId ?? await resolveCodexAccountId(file);
|
||||
@@ -871,9 +884,10 @@ async function loadQuotaSnapshot(file: AuthFile): Promise<QuotaState> {
|
||||
return {
|
||||
status: 'success',
|
||||
rows,
|
||||
plan: detectedPlan
|
||||
plan: (provider === 'devin' ? readDevinQuota(payload).plan : detectedPlan)
|
||||
?? (readString(isRecord(payload) ? payload : {}, 'plan_type', 'planType') || codexMetadata?.plan),
|
||||
subscriptionActiveUntil: codexMetadata?.subscriptionActiveUntil,
|
||||
subscriptionActiveUntil: provider === 'devin'
|
||||
? readDevinQuota(payload).subscriptionActiveUntil : codexMetadata?.subscriptionActiveUntil,
|
||||
resetCreditsError,
|
||||
resetCreditsApplicable: usageCreditDetails.applicableAvailableCount
|
||||
?? resetCreditDetails?.applicableAvailableCount ?? resetCredits,
|
||||
|
||||
@@ -3310,6 +3310,10 @@ textarea:focus-visible {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .devin-logo {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.provider-logo {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const base = process.env.DEVIN_TEST_BASE_URL || 'http://127.0.0.1:1421';
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(String(error)));
|
||||
await page.route('**/*', route => route.request().url().startsWith(base + '/') ? route.continue() : route.abort());
|
||||
const open = query => page.goto(base + '/tests/fixtures/devin.html?' + (query || ''), { waitUntil: 'domcontentloaded' });
|
||||
const card = () => page.locator('.oauth-card').filter({ hasText: 'Devin OAuth' });
|
||||
const click = label => card().getByRole('button', { name: label, exact: true }).click();
|
||||
const calls = () => page.evaluate(() => window.devinFixture.calls);
|
||||
await open();
|
||||
await card().waitFor();
|
||||
assert.equal(await page.locator('.oauth-card').count(), 6);
|
||||
await click('Start Sign-In');
|
||||
await card().getByRole('textbox').waitFor();
|
||||
assert.ok((await calls()).some(call => call.cmd === 'start_oauth_login' && call.args.provider === 'devin' && call.args.browser === 'none'));
|
||||
await card().getByRole('textbox').fill('http://127.0.0.1:8317/callback?state=old&code=code');
|
||||
await click('Submit Callback');
|
||||
await page.getByText('The callback state does not match', { exact: false }).waitFor();
|
||||
assert.equal((await calls()).filter(call => call.cmd === 'submit_oauth_callback').length, 0);
|
||||
await card().getByRole('textbox').fill('http://127.0.0.1:8317/callback?state=attempt-1&code=code');
|
||||
await click('Submit Callback');
|
||||
await card().getByText('Completed', { exact: true }).waitFor();
|
||||
assert.equal(await card().getByRole('textbox').count(), 0);
|
||||
|
||||
await open();
|
||||
await click('Start Sign-In');
|
||||
await card().getByRole('textbox').waitFor();
|
||||
await page.evaluate(() => { window.devinFixture.holdStatus = true; });
|
||||
await page.waitForFunction(() => window.devinFixture.releaseStatus !== null);
|
||||
await click('Refresh Link');
|
||||
await page.waitForFunction(() => window.devinFixture.attempts === 2);
|
||||
await page.evaluate(() => window.devinFixture.releaseStatus());
|
||||
await page.waitForTimeout(150);
|
||||
assert.equal(await card().getByText('Completed', { exact: true }).count(), 0);
|
||||
assert.ok(await card().getByRole('textbox').isVisible());
|
||||
assert.ok((await calls()).some(call => call.cmd === 'management_request' && call.args.request.path === '/oauth-session' && call.args.request.query.state === 'attempt-1'));
|
||||
await page.evaluate(() => { window.devinFixture.cancelError = true; });
|
||||
await click('Refresh Link');
|
||||
await page.getByText('Cancellation failed', { exact: false }).waitFor();
|
||||
assert.equal(await page.evaluate(() => window.devinFixture.attempts), 2);
|
||||
|
||||
await open('view=easy');
|
||||
const easy = page.locator('.simple-mode-provider-card').filter({ hasText: 'Devin OAuth' });
|
||||
await easy.getByRole('button').click();
|
||||
await page.waitForFunction(() => window.devinFixture.attempts === 1);
|
||||
await page.evaluate(() => { window.devinFixture.status = 'ok'; });
|
||||
await page.waitForFunction(() => document.querySelectorAll('.simple-mode-provider-card.connected').length === 1);
|
||||
assert.ok((await calls()).some(call => call.cmd === 'start_oauth_login' && call.args.provider === 'devin'));
|
||||
|
||||
await open('view=files');
|
||||
await page.getByText('devin-test.json', { exact: true }).waitFor();
|
||||
await page.getByRole('button', { name: 'Fetch Quota', exact: true }).click();
|
||||
await page.getByText('75%', { exact: false }).first().waitFor();
|
||||
assert.equal(await page.locator('.devin-logo').count(), 1);
|
||||
|
||||
await open('view=quota');
|
||||
await page.getByRole('button', { name: 'Fetch Quota', exact: true }).click();
|
||||
await page.getByText('Daily quota', { exact: true }).waitFor();
|
||||
assert.ok((await page.locator('.real-quota-card').innerText()).includes('Pro'));
|
||||
assert.equal(await page.locator('.real-quota-track').count(), 2);
|
||||
assert.equal(await page.locator('.real-quota-track span').last().evaluate(node => node.style.width), '0%');
|
||||
await page.evaluate(() => { window.devinFixture.quotaError = true; });
|
||||
await page.getByRole('button', { name: 'Refresh All', exact: true }).click();
|
||||
await page.getByText('Session expired', { exact: false }).waitFor();
|
||||
assert.equal(await page.locator('.real-quota-track').count(), 0);
|
||||
|
||||
fs.mkdirSync('misc', { recursive: true });
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await open('theme=' + theme + '&locale=zh-CN');
|
||||
await card().waitFor();
|
||||
await page.screenshot({ path: 'misc/devin-oauth-' + theme + '.png', fullPage: true });
|
||||
const filter = await page.locator('.devin-logo').evaluate(node => getComputedStyle(node).filter);
|
||||
assert.equal(filter, theme === 'dark' ? 'invert(1)' : 'none');
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('Devin browser checks passed: OAuth, callback validation, session refresh, simplified login, auth files, quota and both themes.');
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterEach, describe, expect, it, spyOn } from 'bun:test';
|
||||
import { validateDevinCallback } from '../src/services/devinOAuth';
|
||||
import { readDevinQuota } from '../src/services/devinQuota';
|
||||
import { loadQuota, providerForFile, quotaRowsFor } from '../src/services/quotaService';
|
||||
import { changedOAuthAuthFileNames, snapshotAuthFiles } from '../src/services/authFiles';
|
||||
import { managementApi } from '../src/services/managementApi';
|
||||
import { loadOAuthModelSettings, saveOAuthModelSettings } from '../src/services/oauthModelSettings';
|
||||
|
||||
const liveStatus = (fields: Record<string, unknown>) => ({ userStatus: { planStatus: fields } });
|
||||
const file = { name: 'devin-test.json', provider: 'devin', auth_index: 'devin-index' };
|
||||
const mocks: Array<{ mockRestore(): void }> = [];
|
||||
afterEach(() => { mocks.splice(0).forEach((mock) => mock.mockRestore()); });
|
||||
|
||||
describe('Devin OAuth', () => {
|
||||
it('accepts the complete redirect and denial callback from the current attempt', () => {
|
||||
expect(validateDevinCallback(' http://127.0.0.1:8317/callback?state=current&code=abc ', 'current')).toBeUndefined();
|
||||
expect(validateDevinCallback('https://remote.example/callback?state=current&error=access_denied', 'current')).toBeUndefined();
|
||||
});
|
||||
it('rejects missing, duplicated or stale state and never manufactures a callback', () => {
|
||||
for (const input of ['abc', 'code=abc', 'file:///callback?state=current&code=a', 'http://localhost/callback?code=abc', 'http://localhost/callback?state=current', 'http://localhost/callback?state=current&state=current&code=a']) {
|
||||
expect(validateDevinCallback(input, 'current')).toBe('invalid');
|
||||
}
|
||||
expect(validateDevinCallback('http://localhost/callback?state=old&code=a', 'current')).toBe('state_mismatch');
|
||||
expect(validateDevinCallback('http://localhost/callback?state=current&code=a')).toBe('state_mismatch');
|
||||
});
|
||||
it('recognizes Devin and Cognition files when applying defaults to newly created credentials', () => {
|
||||
expect(providerForFile(file)).toBe('devin');
|
||||
expect(providerForFile({ type: 'cognition' })).toBe('devin');
|
||||
const old = { ...file, priority: 5 };
|
||||
expect(changedOAuthAuthFileNames(snapshotAuthFiles([old]), [old, { name: 'new.json', type: 'cognition' }], 'devin')).toEqual(['new.json']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Devin live quota', () => {
|
||||
it('preserves zero and fractional remaining percentages and Unix reset times', () => {
|
||||
const result = readDevinQuota(JSON.stringify(liveStatus({
|
||||
dailyQuotaRemainingPercent: 0, weeklyQuotaRemainingPercent: '81.25',
|
||||
dailyQuotaResetAtUnix: '1893456000', weeklyQuotaResetAtUnix: 1893542400,
|
||||
planInfo: { planName: ' Pro ' }, planEnd: '2030-02-01T00:00:00Z',
|
||||
})));
|
||||
expect(result).toMatchObject({ plan: 'Pro', subscriptionActiveUntil: '2030-02-01T00:00:00Z', windows: [
|
||||
{ id: 'daily', remainingPercent: 0, resetAtMs: 1893456000000 },
|
||||
{ id: 'weekly', remainingPercent: 81.25, resetAtMs: 1893542400000 },
|
||||
] });
|
||||
expect(quotaRowsFor('devin', liveStatus({ dailyQuotaRemainingPercent: 0 }))).toEqual([
|
||||
{ label: '每日额度', remainingPercent: 0, resetAtMs: undefined },
|
||||
{ label: '每周额度', remainingPercent: null, resetAtMs: undefined },
|
||||
]);
|
||||
});
|
||||
it('does not turn malformed, missing or cached metadata into live quota', () => {
|
||||
for (const value of [-1, 101, Infinity, true, '', ' ', '0x10', {}, null]) {
|
||||
expect(readDevinQuota(liveStatus({ dailyQuotaRemainingPercent: value })).windows).toEqual([]);
|
||||
}
|
||||
expect(readDevinQuota({ quota: { signals: { daily_quota_remaining_percent: 100 } } }).windows).toEqual([]);
|
||||
expect(readDevinQuota('invalid JSON').windows).toEqual([]);
|
||||
expect(readDevinQuota(liveStatus({ dailyQuotaResetAtUnix: '1.5', weeklyQuotaResetAtUnix: '9999999999999999999' })).windows).toEqual([]);
|
||||
expect(readDevinQuota(liveStatus({ weeklyQuotaResetAtUnix: 1893456000 })).windows[0].remainingPercent).toBeNull();
|
||||
});
|
||||
it('uses the selected auth index with a body placeholder and shares concurrent refreshes', async () => {
|
||||
const post = spyOn(managementApi, 'post').mockResolvedValue({ status_code: 200, body: liveStatus({ dailyQuotaRemainingPercent: 75, weeklyQuotaRemainingPercent: 50, planInfo: { planName: 'Pro' } }) } as never);
|
||||
const get = spyOn(managementApi, 'get').mockRejectedValue(new Error('Must not download credentials'));
|
||||
mocks.push(post, get);
|
||||
const first = loadQuota(file);
|
||||
const second = loadQuota(file);
|
||||
expect(first).toBe(second);
|
||||
expect(await first).toMatchObject({ status: 'success', plan: 'Pro', rows: [{ remainingPercent: 75 }, { remainingPercent: 50 }] });
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
const [path, body] = post.mock.calls[0];
|
||||
expect(path).toBe('/api-call');
|
||||
expect(body).toMatchObject({ authIndex: 'devin-index', method: 'POST', url: 'https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus', header: { 'Content-Type': 'application/json', 'Connect-Protocol-Version': '1' } });
|
||||
expect(JSON.parse((body as { data: string }).data)).toEqual({ metadata: { ideName: 'chisel', ideVersion: '3000.10.21', apiKey: '$TOKEN$', locale: 'en', os: 'darwin', extensionVersion: '3000.10.21', clientName: 'chisel' } });
|
||||
await loadQuota(file);
|
||||
expect(post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('refuses disabled credentials and incomplete identities without upstream requests', async () => {
|
||||
const post = spyOn(managementApi, 'post');
|
||||
mocks.push(post);
|
||||
for (const entry of [{ ...file, disabled: true }, { ...file, auth_index: undefined }, { ...file, name: '' }]) {
|
||||
expect(await loadQuota(entry)).toMatchObject({ status: 'error', rows: [] });
|
||||
}
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
it('reports upstream errors and empty successful responses', async () => {
|
||||
const post = spyOn(managementApi, 'post').mockResolvedValue({ status_code: 401, body: { error: 'expired' } } as never);
|
||||
mocks.push(post);
|
||||
expect(await loadQuota(file)).toMatchObject({ status: 'error', rows: [], error: 'expired' });
|
||||
post.mockResolvedValue({ status_code: 200, body: liveStatus({ planInfo: { planName: 'Pro' } }) } as never);
|
||||
expect(await loadQuota(file)).toMatchObject({ status: 'error', rows: [] });
|
||||
});
|
||||
});
|
||||
|
||||
it('loads and saves Devin model exclusions under its own provider channel', async () => {
|
||||
const calls: unknown[] = [];
|
||||
const api = {
|
||||
get: async (path: string) => {
|
||||
calls.push(path);
|
||||
return path === '/model-definitions/devin'
|
||||
? { models: [{ id: 'devin/swe-2', display_name: 'SWE-2' }] }
|
||||
: { 'oauth-excluded-models': { devin: ['devin/old'] } };
|
||||
},
|
||||
patch: async (path: string, body: Record<string, unknown>) => { calls.push([path, body]); },
|
||||
delete: async () => {},
|
||||
};
|
||||
const settings = await loadOAuthModelSettings({ scope: 'provider', provider: 'devin', label: 'Devin' }, api);
|
||||
expect(settings.models.map((model) => model.id)).toEqual(['devin/old', 'devin/swe-2']);
|
||||
await saveOAuthModelSettings(settings, ['devin/swe-2'], api);
|
||||
expect(calls).toContainEqual(['/oauth-excluded-models', { provider: 'devin', models: ['devin/swe-2'] }]);
|
||||
});
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Devin integration</title></head><body><div id="root"></div><script type="module" src="./devin.tsx"></script></body></html>
|
||||
Vendored
+67
@@ -0,0 +1,67 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { mockIPC } from '@tauri-apps/api/mocks';
|
||||
import { I18nProvider } from '../../src/i18n';
|
||||
import { OAuthLoginPage } from '../../src/pages/ManagementPages';
|
||||
import { QuotaPage } from '../../src/pages/QuotaPage';
|
||||
import { AuthFileManagementPage } from '../../src/pages/AuthFileManagementPage';
|
||||
import { EasyModePage } from '../../src/pages/EasyModePage';
|
||||
import '../../src/styles.css';
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const view = params.get('view');
|
||||
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') ?? 'en');
|
||||
localStorage.setItem('easy-cli-proxy-api.oauth-browser.v3', 'none');
|
||||
document.documentElement.dataset.theme = params.get('theme') ?? 'light';
|
||||
const file = { name: 'devin-test.json', provider: 'devin', auth_index: 'devin-index', status: 'active' };
|
||||
const fixture = {
|
||||
calls: [] as Array<{ cmd: string; args: any }>,
|
||||
attempts: 0,
|
||||
status: 'wait',
|
||||
cancelError: false,
|
||||
quotaError: false,
|
||||
holdStatus: false,
|
||||
releaseStatus: null as null | (() => void),
|
||||
files: view === 'quota' || view === 'files' ? [file] : [] as typeof file[],
|
||||
};
|
||||
Object.assign(window, { devinFixture: fixture });
|
||||
mockIPC(async (cmd, args: any) => {
|
||||
fixture.calls.push({ cmd, args });
|
||||
if (cmd === 'set_app_locale') return null;
|
||||
if (cmd === 'list_oauth_browsers') return [{ id: 'default', label: 'System Default' }];
|
||||
if (cmd === 'start_oauth_login') {
|
||||
fixture.attempts++;
|
||||
fixture.status = 'wait';
|
||||
return { url: 'https://auth.devin.ai/authorize?state=attempt-' + fixture.attempts, state: 'attempt-' + fixture.attempts, opened: true };
|
||||
}
|
||||
if (cmd === 'get_oauth_status') {
|
||||
if (fixture.holdStatus) {
|
||||
fixture.holdStatus = false;
|
||||
return new Promise((resolve) => { fixture.releaseStatus = () => resolve({ status: 'ok' }); });
|
||||
}
|
||||
if (fixture.status === 'ok') fixture.files = [file];
|
||||
return { status: fixture.status };
|
||||
}
|
||||
if (cmd === 'submit_oauth_callback') { fixture.status = 'ok'; return null; }
|
||||
if (cmd === 'open_oauth_url') return null;
|
||||
if (cmd === 'management_request') {
|
||||
const request = args.request;
|
||||
if (request.path === '/auth-files') return { files: fixture.files };
|
||||
if (request.path === '/config') return {};
|
||||
if (request.path === '/auth-files/fields') return {};
|
||||
if (request.path === '/oauth-session') {
|
||||
if (fixture.cancelError) throw new Error('Cancellation failed');
|
||||
return { status: 'ok', cancelled: true };
|
||||
}
|
||||
if (request.path === '/api-call') return fixture.quotaError
|
||||
? { status_code: 401, body: { error: 'Session expired' } }
|
||||
: { status_code: 200, body: { userStatus: { planStatus: {
|
||||
dailyQuotaRemainingPercent: 75, weeklyQuotaRemainingPercent: 0,
|
||||
dailyQuotaResetAtUnix: '1893456000', weeklyQuotaResetAtUnix: '1893542400',
|
||||
planInfo: { planName: 'Pro' }, planEnd: '2030-02-01T00:00:00Z',
|
||||
} } } };
|
||||
}
|
||||
throw new Error('Unexpected command: ' + cmd + ' ' + JSON.stringify(args));
|
||||
}, { shouldMockEvents: true });
|
||||
createRoot(document.getElementById('root')!).render(<I18nProvider><main style={{ padding: 24 }}>
|
||||
{view === 'quota' ? <QuotaPage /> : view === 'files' ? <AuthFileManagementPage /> : view === 'easy' ? <EasyModePage /> : <OAuthLoginPage />}
|
||||
</main></I18nProvider>);
|
||||
Reference in new issue
Block a user